主人 2026-10-07 三点裁定:「下划线确实可以保留」「多余的空气泡确实可以丢掉」
「非聊天模式 按照应当有的专业卡片和格式来显示」。
- 白名单回到六项(u 加回,s 仍在外)
- 空 p 块也丢(与 br 碎片同一口径);纯注记回复 ⇒ 零气泡
- 非 chat 模式(8 个单卡正文)给它自己的渲染口径 CARD_RENDER:
br ⇒ 真换行元素(那些容器没有 whitespace-pre-wrap,文本换行会被 CSS 折叠)、
行内白名单正常出样式;WorkMessageBody 的按行切清单、CodeMessageBody 的 pre
等既有较强约定一律保留
- 🔴 发现并修了一个会炸的下游损失:空气泡原本是承载头像/思维链/工具卡/心情/
「生成中…」的那一行,丢气泡会把这些用户可见内容一起丢 ⇒ 加 hasBubbleChrome,
补一行无正文外壳
- 测试 99 -> 189 checks;6 个变异(含「外壳行摘掉 ⇒ 工具卡丢失」)
- 改掉一条既有断言:三个 p 中首段被注记剥空 ⇒ 气泡数由 3 改为 2(并补按行数断言)
452 lines
29 KiB
TypeScript
452 lines
29 KiB
TypeScript
// 行内白名单标签的渲染测试(主人 2026-10-07:「聊天消息里插入了 html/md 标签,能否
|
||
// 增加网页前端的基本渲染支持」)。
|
||
//
|
||
// 为什么需要它:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落 `<p>`(契约
|
||
// §4.16)、动作 `<action>`(气泡外灰斜体注记)此前都已处理,**行内**标签(实测 12 条
|
||
// assistant 消息里有 6 个 `<b>`)没有处理 ⇒ 用户直接看到字面的 `<b>`。
|
||
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
|
||
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
|
||
//
|
||
// 分九组:
|
||
// ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中),
|
||
// 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/
|
||
// `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样;
|
||
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
|
||
// 树里不出现这些元素、不出现任何来自产物的属性;
|
||
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
|
||
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
|
||
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
|
||
// ⑥ 既有 `<p>` / `<action>` 行为(走真实的 SplitChatBubble 链路)—— 含 **2026-10-07 晚
|
||
// 的口径变更:空 `<p>` 块不再产出空气泡**(旧的「三个 `<p>` ⇒ 三个气泡」断言随之改为
|
||
// 两个气泡);
|
||
// ⑦ `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为
|
||
// 多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行;
|
||
// ⑧ 🆕 **纯动作 / 纯独白回复 ⇒ 零个气泡**(不崩、不画空气泡),且工具卡 / 思维链 / 心情
|
||
// 这些**用户可见内容**不能跟着被丢;
|
||
// ⑨ 🆕 **非 chat 单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置):
|
||
// `<br>` ⇒ **换行**(那些界面没有「多条气泡」的概念,所以不切分)、行内白名单出样式、
|
||
// 白名单外仍逐字字面;chat 模式的 `<br>` 分隔口径不受影响(⑨ 里有一条回归断言)。
|
||
//
|
||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||
//
|
||
// cd frontend/web \
|
||
// && ./node_modules/.bin/esbuild src/lib/renderContent.render.test.ts \
|
||
// --bundle --platform=node --format=cjs \
|
||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||
// --outfile=/tmp/cyrene-inline-markup.test.cjs \
|
||
// && node /tmp/cyrene-inline-markup.test.cjs
|
||
//
|
||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||
// 不 import node:*。
|
||
|
||
import { createElement, Fragment, type ReactNode } from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { renderContent } from "./renderContent";
|
||
import { SplitChatBubble } from "../components/chat";
|
||
import { modeBody } from "../components/messages";
|
||
import type { ChatMessage } from "./types";
|
||
|
||
let checks = 0;
|
||
|
||
function ok(cond: boolean, what: string): void {
|
||
checks += 1;
|
||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||
}
|
||
|
||
function has(html: string, needle: string): boolean {
|
||
return html.includes(needle);
|
||
}
|
||
|
||
function count(html: string, needle: string): number {
|
||
return html.split(needle).length - 1;
|
||
}
|
||
|
||
/** 渲染一段(`<p>` 切分后的)段落文本,取静态 HTML。 */
|
||
function markup(text: string): string {
|
||
return renderToStaticMarkup(createElement(Fragment, null, renderContent(text)));
|
||
}
|
||
|
||
/** 还原成「用户实际看见的字」:先剥掉**真元素**的标记(含 React 的相邻文本分隔注释),
|
||
* 再还原实体。顺序不能反 —— 先解码再剥标签会把降级后的字面量 `<b>` 也当成标签误删。 */
|
||
function decode(html: string): string {
|
||
return html
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/&/g, "&");
|
||
}
|
||
|
||
/** 渲染后**用户实际看见的字**。 */
|
||
function seen(text: string): string {
|
||
return decode(markup(text));
|
||
}
|
||
|
||
interface ElInfo {
|
||
type: string;
|
||
propKeys: string[];
|
||
}
|
||
|
||
/** 走一遍 `renderContent` 产出的 React 元素树,列出「元素类型 + 非 children 的属性名」。
|
||
* 这是「有没有不该出现的节点 / 属性」最直接的一层断言 —— 看的是树,不是字符串。 */
|
||
function tree(text: string): ElInfo[] {
|
||
const out: ElInfo[] = [];
|
||
const walk = (node: ReactNode): void => {
|
||
if (node === null || node === undefined || typeof node !== "object") return;
|
||
if (Array.isArray(node)) {
|
||
(node as ReactNode[]).forEach(walk);
|
||
return;
|
||
}
|
||
const el = node as unknown as { type: unknown; props?: Record<string, unknown> };
|
||
const kind = el.type;
|
||
const name =
|
||
typeof kind === "string"
|
||
? kind
|
||
: ((kind as { displayName?: string; name?: string } | null)?.displayName ??
|
||
(kind as { name?: string } | null)?.name ??
|
||
"Fragment");
|
||
const props = el.props ?? {};
|
||
out.push({ type: name, propKeys: Object.keys(props).filter((k) => k !== "children") });
|
||
walk(props.children as ReactNode);
|
||
};
|
||
walk(renderContent(text));
|
||
return out;
|
||
}
|
||
|
||
function elementTypes(text: string): string[] {
|
||
return tree(text).map((e) => e.type);
|
||
}
|
||
|
||
/** 除最外层 span 与承载纯文本的 Fragment 之外的节点类型 —— 应当为空,即「没有造出
|
||
* 任何额外节点」(Fragment 是纯文本的载体,不产生 DOM)。 */
|
||
function foreignTypes(text: string): string[] {
|
||
return elementTypes(text).filter((t) => t !== "span" && t !== "Fragment");
|
||
}
|
||
|
||
function render(message: ChatMessage): string {
|
||
return renderToStaticMarkup(createElement(SplitChatBubble, { message }));
|
||
}
|
||
|
||
const REAL_SAMPLE =
|
||
"<p><action>赶紧凑过来看你那边,手指在空气里划了下</action></p>\n" +
|
||
"<p>花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪</p>\n" +
|
||
"<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>";
|
||
|
||
// ── ① 白名单标签渲染成真元素 ────────────────────────────────────────────────────
|
||
{
|
||
const html = markup("日落 <b>18:35</b> 了");
|
||
ok(has(html, "<b>18:35</b>"), "① `<b>` 渲染成粗体元素");
|
||
ok(!has(html, "<b>"), "① 不再把 `<b>` 当字面量显示");
|
||
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
|
||
|
||
// 白名单 = **六项**(主人 2026-10-07 晚先收窄到五项,当日复核后把 `u` 加回:
|
||
// 「下划线确实可以保留」)⇒ `<u>` 从此渲染成下划线元素,不再是字面量。
|
||
const tags: [string, string][] = [
|
||
["<b>粗</b>", "<b>粗</b>"],
|
||
["<strong>粗</strong>", "<strong>粗</strong>"],
|
||
["<i>斜</i>", "<i>斜</i>"],
|
||
["<em>强调</em>", "<em>强调</em>"],
|
||
["<u>下划</u>", "<u>下划</u>"],
|
||
];
|
||
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
|
||
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
|
||
ok(seen("<code>go build</code>") === "go build", "① code 内文本逐字保留");
|
||
ok(seen("<u>下划</u>") === "下划", "① `u` 的标签本身不作为文字出现(只剩正文)");
|
||
ok(foreignTypes("<u>下划</u>").join(",") === "u", "① `u` 是一个真元素(不是字面量)");
|
||
|
||
// 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
|
||
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
|
||
|
||
// 嵌套白名单标签(正常闭合)照常渲染。
|
||
const nested = markup("<b>外 <i>内</i></b>");
|
||
ok(has(nested, "<b>外 <i>内</i></b>"), "① 正常嵌套逐层渲染");
|
||
|
||
// 🔴 只有 `s` 出列(`u` 已加回白名单):它按**白名单外**口径逐字原样,不产出元素 ——
|
||
// 别顺手把 `s` 也加回来。
|
||
ok(seen("<s>划掉</s>") === "<s>划掉</s>", "① 出列的 `<s>` 逐字原样显示(字符不丢)");
|
||
ok(!has(markup("<s>划掉</s>"), "<s>"), "① 不再为 `s` 产出元素");
|
||
}
|
||
|
||
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
|
||
{
|
||
const cases: [string, string, string][] = [
|
||
["<script>alert(1)</script>", "<script", "script"],
|
||
["<img src=x onerror=alert(1)>", "<img", "img"],
|
||
["<div>块级不做</div>", "<div", "div"],
|
||
["<ul><li>列表不做</li></ul>", "<ul", "ul"],
|
||
["<a href=\"javascript:alert(1)\">链接</a>", "<a ", "a"],
|
||
["<iframe src=\"https://x/\"></iframe>", "<iframe", "iframe"],
|
||
["<style>body{display:none}</style>", "<style", "style"],
|
||
];
|
||
for (const [src, forbidden, name] of cases) {
|
||
ok(!has(markup(src), forbidden), `② 不产出 <${name}> 元素`);
|
||
ok(seen(src) === src, `② 白名单外的 \`${name}\` 逐字原样显示(不丢字符)`);
|
||
}
|
||
ok(
|
||
seen("<script>alert(1)</script>") === "<script>alert(1)</script>",
|
||
"② `<script>` 显示为字面量文本,不是可执行节点"
|
||
);
|
||
ok(
|
||
foreignTypes("<script>alert(1)</script>").length === 0,
|
||
"② 元素树里除最外层 span 外没有任何节点"
|
||
);
|
||
|
||
// 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。
|
||
for (const e of tree("<img src=x onerror=alert(1)>")) {
|
||
ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`);
|
||
}
|
||
}
|
||
|
||
// ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ────────────────────────────────────
|
||
{
|
||
const broken = [
|
||
"<b>x",
|
||
"日落 <b>18:35 了",
|
||
"</b>孤立闭标签",
|
||
"<b>x <i>y</i>", // 开 b 未闭合(i 已闭合)⇒ 整段降级
|
||
"<b><i>x</b>", // 交错(i 未完)⇒ 整段降级
|
||
];
|
||
for (const src of broken) {
|
||
const html = markup(src);
|
||
ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`);
|
||
ok(!has(html, "<b>") && !has(html, "<i>"), `③ 降级后不产出元素:${src}`);
|
||
ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`);
|
||
}
|
||
ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签");
|
||
ok(seen("a<b") === "a<b", "③ §4.16 护栏:`a<b`(无 `>`)不是标签");
|
||
ok(seen("<p2>x</p2>") === "<p2>x</p2>", "③ §4.16 护栏:`<p2>` 不是标签");
|
||
ok(seen("文本 <p 无尖括号") === "文本 <p 无尖括号", "③ §4.16 护栏:`<p`(无 `>`)不是标签");
|
||
}
|
||
|
||
// ── ④ 交错标签:不崩、字符一个不丢 ──────────────────────────────────────────────
|
||
{
|
||
const interleaved = "<b>a<i>b</b>c</i>";
|
||
ok(seen(interleaved) === interleaved, "④ `<b>a<i>b</b>c</i>` 逐字保留,不崩");
|
||
ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)");
|
||
|
||
// 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `<b>` 对
|
||
// 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。
|
||
const mixed = "日落 <b>18:35</b>,还有 <i>未闭合";
|
||
ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢");
|
||
ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素");
|
||
}
|
||
|
||
// ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ────────────────────────────────────────────
|
||
{
|
||
const html = markup("<b class=x onclick=y>z</b>");
|
||
ok(has(html, "<b>z</b>"), "⑤ 只出粗体元素本身");
|
||
ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup");
|
||
|
||
const bEl = tree("<b class=x onclick=y>z</b>").find((e) => e.type === "b");
|
||
ok(Boolean(bEl), "⑤ 树里存在 b 元素");
|
||
ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)");
|
||
|
||
ok(seen("<b class=x onclick=y>z</b>") === "z", "⑤ 属性被丢弃,正文逐字保留");
|
||
|
||
const codeHtml = markup("<code data-x=1 style=color:red>c</code>");
|
||
ok(has(codeHtml, "<code") && !has(codeHtml, "data-x") && !has(codeHtml, "color:red"), "⑤ code 上只保留自己的样式类");
|
||
|
||
ok(seen("<b title=\"点我\">提示</b>") === "提示", "⑤ 引号含空格的属性同样不生效");
|
||
}
|
||
|
||
// ── ⑥ 既有 `<p>` / `<action>` 行为无回归(真实气泡链路) ────────────────────────
|
||
{
|
||
const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage);
|
||
|
||
ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记");
|
||
// 🔴 **断言变更**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):首段是
|
||
// `<p><action>…</action></p>`,注记被剥走后只剩空壳 `<p></p>` ⇒ 与 `<br>` 碎片同一套
|
||
// 口径,**不再产出空气泡**。原断言为 `count(…) === 3`,理由是「首段只有动作注记 ⇒
|
||
// 空气泡,与改动前逐字一致」;现为 **2**(就是正文那两段)。
|
||
ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ 三个 <p> 里首段被注记剥空 ⇒ 只剩两个气泡(空气泡丢弃)");
|
||
// 光数「气泡正文」还不够 —— 空段若仍在,会留下一行「只有头像、没有正文」的空行
|
||
// (`ChatBody` 对空正文返回 null ⇒ 空气泡的**壳**没了,但那一行仍在)。所以按**行数**再锁一次。
|
||
ok(count(html, "mu-rise-in flex gap-3") === 2, "⑥ 首段空壳连「气泡行」一起消失(不是只把壳画没了)");
|
||
ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
|
||
ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体");
|
||
ok(!has(html, "<p>") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)");
|
||
ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)");
|
||
|
||
// 纯 `<p>` 三段 ⇒ 三个气泡(首段 full、其余 compact)。
|
||
const three = render({ id: "a2", role: "assistant", content: "<p>第一段</p><p>第二段</p><p>第三段</p>", createdAt: 2 } as ChatMessage);
|
||
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 <p> 三段 ⇒ 三个气泡");
|
||
ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
|
||
|
||
// 🆕 **空 `<p>` 块也丢**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):与 `<br>`
|
||
// 碎片同一套口径 —— 上一轮只丢了 `<br>` 碎片,空的 `<p>` 仍留一个空气泡,本轮改掉。
|
||
const emptyP = render({ id: "a5", role: "assistant", content: "<p></p><p>只有这段</p>", createdAt: 5 } as ChatMessage);
|
||
ok(count(emptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1, "⑥ 空 `<p></p>` 块丢弃 ⇒ 只剩正文那一个气泡");
|
||
ok(count(emptyP, "mu-rise-in flex gap-3") === 1, "⑥ 空 `<p></p>` 块不留「只有头像」的空行");
|
||
const blankP = render({ id: "a6", role: "assistant", content: "<p>甲</p><p> </p><p>乙</p>", createdAt: 6 } as ChatMessage);
|
||
ok(count(blankP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ `<p> </p>`(全空白)同样丢弃 ⇒ 两个气泡");
|
||
ok(count(blankP, "mu-rise-in flex gap-3") === 2, "⑥ `<p> </p>` 不留「只有头像」的空行");
|
||
const onlyEmptyP = render({ id: "a7", role: "assistant", content: "<p></p>", createdAt: 7 } as ChatMessage);
|
||
ok(count(onlyEmptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1 && !has(onlyEmptyP, "<p>"), "⑥ 正文只剩空 `<p>` ⇒ 不画字面量 `<p>`,只留空回复外壳(与只剩 `<br>` 同一处置)");
|
||
|
||
// 🔴 `<action>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
|
||
const noteWithTag = render({ id: "a3", role: "assistant", content: "<action>她 <b>笑</b> 了</action><p>正文</p>", createdAt: 3 } as ChatMessage);
|
||
ok(has(noteWithTag, "*动作:她 <b>笑</b> 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
|
||
ok(has(noteWithTag, "正文"), "⑥ 注记后的正文照常渲染");
|
||
|
||
// 空正文 + streaming:仍是「生成中…」而不是空渲染。
|
||
const streaming = render({ id: "a4", role: "assistant", content: "", streaming: true, createdAt: 4 } as ChatMessage);
|
||
ok(has(streaming, "生成中…"), "⑥ 空正文 streaming 回落到「生成中…」");
|
||
|
||
// 用户侧同理(用户气泡也走 renderContent)。
|
||
const user = render({ id: "u1", role: "user", content: "帮我看看 <b>这个</b> 和 <script>x</script>", createdAt: 5 } as ChatMessage);
|
||
ok(has(user, "<b>这个</b>"), "⑥ 用户气泡同样渲染行内标签");
|
||
ok(!has(user, "<script") && has(user, "<script>x</script>"), "⑥ 用户气泡里的白名单外标签仍是字面量");
|
||
}
|
||
|
||
// ── ⑦ `<br>` = 气泡/消息分隔(与 `<p>` 同级,**不是**气泡内换行) ────────────────
|
||
// 主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条消息隔断」。这一组全部走
|
||
// **真实链路**(`SplitChatBubble` → `splitBubble` → `splitParagraphs` → `renderContent`),
|
||
// 所以它同时是「上一轮把 `<br>` 当行内换行」那个错误实现的**反向断言**。
|
||
{
|
||
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||
const asst = (content: string) => render({ id: "br1", role: "assistant", content, createdAt: 1 } as ChatMessage);
|
||
|
||
// 主人给的四条口径。
|
||
const inP = asst("<p>甲<br>乙</p>");
|
||
ok(count(inP, BUBBLE) === 2, "⑦ `<p>甲<br>乙</p>` ⇒ 两个气泡");
|
||
ok(has(inP, "<span>甲</span>") && has(inP, "<span>乙</span>"), "⑦ 两个气泡的正文分别是 甲 / 乙");
|
||
ok(!has(inP, "<br/>") && !has(inP, "<br"), "⑦ `<br>` 既不进 DOM、也不作为字面量出现在正文里");
|
||
|
||
const noP = asst("甲<br>乙");
|
||
ok(count(noP, BUBBLE) === 2, "⑦ 无 `<p>` 时 `甲<br>乙` ⇒ 两个气泡");
|
||
ok(has(noP, "<span>甲</span>") && has(noP, "<span>乙</span>"), "⑦ 无 `<p>` 时同样按 `<br>` 切开");
|
||
|
||
ok(count(asst("<p>甲<br><br>乙</p>"), BUBBLE) === 2, "⑦ `<p>甲<br><br>乙</p>` ⇒ 两个气泡(中间空气泡丢弃)");
|
||
ok(count(asst("<p>甲 <br> 乙</p>"), BUBBLE) === 2, "⑦ `<br>` 两侧空白 ⇒ 不产生多余空气泡");
|
||
ok(count(asst("<p>甲<br></p>"), BUBBLE) === 1, "⑦ 尾随 `<br>` 不留空气泡");
|
||
ok(count(asst("<p>甲<br> </p>"), BUBBLE) === 1, "⑦ `<br>` 后全空白不留空气泡");
|
||
|
||
// 正文只剩 `<br>`:气泡分隔把它切光 ⇒ 按空正文交下去(空气泡丢弃,别把字面量画出来)。
|
||
const onlyBr = asst("<br>");
|
||
ok(count(onlyBr, BUBBLE) === 1 && !has(onlyBr, "<br"), "⑦ 正文只剩 `<br>` ⇒ 不产生字面量、只留空回复外壳");
|
||
|
||
// 三种写法等价(HTML5):`<br>` / `<br/>` / `</br>`;属性不解析(`<br class=x>` 照样切)。
|
||
for (const src of ["<p>甲<br>乙</p>", "<p>甲<br/>乙</p>", "<p>甲</br>乙</p>", "<p>甲<br class=\"x\">乙</p>"]) {
|
||
ok(count(asst(src), BUBBLE) === 2, `⑦ 等价写法都切成两个气泡:${src}`);
|
||
}
|
||
|
||
// 护栏:`<brow>` / `<br2>` 不是 br 标签(与 `1 < 2`、`a<b`、`<p2>` 同一条口径)。
|
||
const brow = asst("<p>甲<brow>乙</brow>丙</p>");
|
||
ok(count(brow, BUBBLE) === 1, "⑦ `<brow>` 不是 br ⇒ 不切气泡");
|
||
ok(has(brow, "<brow>"), "⑦ `<brow>` 按白名单外口径逐字原样");
|
||
ok(count(asst("<p>甲<br2>乙</br2>丙</p>"), BUBBLE) === 1, "⑦ `<br2>` / `</br2>` 同样不是 br");
|
||
|
||
// 流式半截 `<br`(还没等到 `>`)⇒ 不是分隔符,本段仍是一个气泡。
|
||
const partial = asst("<p>甲<br</p>");
|
||
ok(count(partial, BUBBLE) === 1, "⑦ 流式半截 `<br`(无 `>`)不切气泡");
|
||
ok(has(partial, "<br"), "⑦ 半截标签按字面量显示(字符不丢)");
|
||
|
||
// 与行内标签混用:切开的两段各自照常做行内渲染。
|
||
const mixed = asst("<p>日落 <b>18:35</b><br>晚安</p>");
|
||
ok(count(mixed, BUBBLE) === 2, "⑦ 含行内标签的段落同样按 `<br>` 切开");
|
||
ok(has(mixed, "<b>18:35</b>") && has(mixed, "<span>晚安</span>"), "⑦ 两段各自渲染(第一段保留粗体)");
|
||
|
||
// 用户气泡走同一条链。
|
||
const userBr = render({ id: "u2", role: "user", content: "<p>甲<br>乙</p>", createdAt: 2 } as ChatMessage);
|
||
ok(count(userBr, "mu-rise-in flex flex-col items-end gap-0.5") === 2, "⑦ 用户气泡同样按 `<br>` 切成两个");
|
||
|
||
// `<action>` 注记不受 `<br>` 影响(注记文案原样、仍在气泡外),正文照常切。
|
||
const noteBr = asst("<action>她 笑 了</action><p>甲<br>乙</p>");
|
||
ok(count(noteBr, BUBBLE) === 2 && has(noteBr, "*动作:她 笑 了*"), "⑦ 注记照旧在气泡外,正文照常切成两段");
|
||
}
|
||
|
||
// ── ⑧ 纯动作 / 纯独白回复 ⇒ **零气泡**(空气泡丢弃的边界 + 下游核实) ──────────────
|
||
{
|
||
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||
const asst = (content: string, extra: Partial<ChatMessage> = {}) =>
|
||
render({ id: "z1", role: "assistant", content, createdAt: 1, ...extra } as ChatMessage);
|
||
|
||
// `<p>` 包的纯注记回复:注记被剥走后只剩空壳 `<p></p>` ⇒ **零气泡**
|
||
// (上一轮这里还是 1 个空气泡;本轮口径变更的边界就在这里)。
|
||
const pureP = asst("<p><action>她点点头</action></p>");
|
||
ok(count(pureP, BUBBLE) === 0, "⑧ `<p><action>…</action></p>` ⇒ 零个气泡");
|
||
ok(has(pureP, "*动作:她点点头*"), "⑧ 注记照常显示(没连注记一起丢)");
|
||
ok(!has(pureP, "<p>") && !has(pureP, "<action>"), "⑧ 零气泡时也不留任何字面标签");
|
||
|
||
// 不带 `<p>` 的纯注记回复(更早的形态):零气泡是**既有行为**,此处锁住不回归。
|
||
ok(count(asst("<action>她点点头</action>"), BUBBLE) === 0, "⑧ 无 `<p>` 的纯注记回复同样零气泡(既有行为不变)");
|
||
ok(count(asst("*她点点头*"), BUBBLE) === 0, "⑧ 全角星号形态的纯注记回复同样零气泡");
|
||
|
||
// 🔴 下游核实:零气泡不等于「什么都不画」—— 思维链 / 工具卡 / 心情是**用户可见内容**,
|
||
// 不能跟着空气泡一起消失,所以补一行**无正文**的外壳(正文本就是空的 ⇒ 没有空气泡)。
|
||
const withChrome = asst("<p><action>她查了下天气</action></p>", {
|
||
thinking: "先查花溪天气",
|
||
mood: "calm",
|
||
tools: [{ name: "web-search", summary: "花溪天气", ok: true, at: 1 }],
|
||
});
|
||
ok(count(withChrome, BUBBLE) === 0, "⑧ 带工具卡的纯注记回复依然零气泡(空气泡没有回来)");
|
||
ok(has(withChrome, "web-search") && has(withChrome, "花溪天气"), "⑧ 零气泡时工具卡仍渲染");
|
||
ok(has(withChrome, "思维链") && has(withChrome, "先查花溪天气"), "⑧ 零气泡时思维链仍渲染");
|
||
ok(has(withChrome, "心情"), "⑧ 零气泡时心情小字仍渲染");
|
||
|
||
// 流式中的零气泡回复:仍要给「生成中…」的等待反馈(这一条外壳是有内容的,不是空气泡)。
|
||
const streamingNotes = asst("<p><action>她正要说</action></p>", { streaming: true });
|
||
ok(count(streamingNotes, BUBBLE) === 1 && has(streamingNotes, "生成中…"), "⑧ 零气泡 + 流式中给「生成中…」反馈(外壳非空)");
|
||
|
||
// 零气泡 + 只有尾注记(注记在正文之后)同样不崩。
|
||
const tailOnly = asst("<p>先说这句</p><p><action>然后她笑了</action></p>");
|
||
ok(count(tailOnly, BUBBLE) === 1 && has(tailOnly, "*动作:然后她笑了*"), "⑧ 尾注记 + 空壳段 ⇒ 一个正文气泡 + 一条注记");
|
||
}
|
||
|
||
// ── ⑨ 非 chat 单卡正文:`<br>` ⇒ 换行、行内出样式、白名单外仍字面 ──────────────────
|
||
// 主人 2026-10-07 晚:「非聊天模式按照应当有的专业卡片和格式来显示」。这些界面是**单卡
|
||
// 正文**(没有「多条气泡」的概念)⇒ `<br>` 只换行、不切分;chat 模式的 `<br>` 分隔口径
|
||
// 不受影响(本组末尾有回归断言)。全部走真实的 `modeBody` 入口(8 个非 chat 模式一个不漏)。
|
||
{
|
||
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||
const CARD_MODES = ["work", "code", "docs", "home", "vision", "voice", "agents", "settings"] as const;
|
||
const src = "甲<br>日落 <b>18:35</b>、<u>下划</u>、<s>划掉</s>、<script>x</script>";
|
||
const card = (mode: (typeof CARD_MODES)[number], content: string) =>
|
||
renderToStaticMarkup(
|
||
createElement(Fragment, null, modeBody(mode, { id: `card-${mode}`, role: "assistant", content, createdAt: 1 } as ChatMessage))
|
||
);
|
||
|
||
for (const mode of CARD_MODES) {
|
||
const html = card(mode, src);
|
||
ok(count(html, "<br/>") === 1, `⑨ ${mode}:\`<br>\` ⇒ 一个真换行元素`);
|
||
ok(!has(html, "<br"), `⑨ ${mode}:不再把 \`<br>\` 当字面量显示`);
|
||
ok(has(html, "<b>18:35</b>"), `⑨ ${mode}:行内 \`<b>\` 出粗体`);
|
||
ok(has(html, "<u>下划</u>"), `⑨ ${mode}:行内 \`<u>\` 出下划线`);
|
||
ok(!has(html, "<s>"), `⑨ ${mode}:\`<s>\` 仍在白名单外(不产出元素)`);
|
||
ok(has(html, "<s>划掉</s>"), `⑨ ${mode}:白名单外的 \`<s>\` 仍逐字字面`);
|
||
ok(has(html, "<script>x</script>"), `⑨ ${mode}:白名单外的 \`<script>\` 逐字字面(不执行、不丢字符)`);
|
||
}
|
||
|
||
// `<br>` 的三种写法在单卡正文里等价(与 chat 侧 BR_SEPARATOR_RE 同一条标签候选口径)。
|
||
for (const s of ["甲<br>乙", "甲<br/>乙", "甲</br>乙", "甲<br class=\"x\">乙"]) {
|
||
ok(count(card("settings", s), "<br/>") === 1, `⑨ 单卡正文里等价写法都换行:${s}`);
|
||
}
|
||
const one = card("docs", "甲<br>乙");
|
||
ok(has(one, "甲") && has(one, "乙"), "⑨ 换行两侧文本都在同一个正文节点里(不切分、不丢字)");
|
||
ok(count(one, "<br") === 0, "⑨ 换行后不留任何字面量");
|
||
|
||
// 护栏:`<brow>` / `<br2>` 不是 br(与 chat 侧、与 `1 < 2` / `a<b` / `<p2>` 同一条口径)。
|
||
ok(count(card("settings", "甲<brow>乙</brow>丙"), "<br/>") === 0, "⑨ `<brow>` 不是 br ⇒ 不换行");
|
||
ok(has(card("settings", "甲<brow>乙</brow>丙"), "<brow>"), "⑨ `<brow>` 按白名单外口径逐字原样");
|
||
ok(count(card("settings", "甲<br2>乙</br2>丙"), "<br/>") === 0, "⑨ `<br2>` / `</br2>` 同样不是 br");
|
||
|
||
// 结构不平衡 ⇒ 仍按既有硬口径:整段降级为原文(连那个 `<br>` 也逐字保留,不换行)。
|
||
const broken = card("settings", "日落 <b>18:35 了<br>晚安");
|
||
ok(has(broken, "<b>18:35 了<br>晚安"), "⑨ 结构不平衡时整段降级为原文(字符一个不丢)");
|
||
ok(count(broken, "<br/>") === 0, "⑨ 降级段不产出任何元素(含换行)");
|
||
|
||
// 🔴 回归:chat 模式的 `<br>` 仍是**气泡分隔符**,绝不因本组而变成换行。
|
||
const chatBr = render({ id: "c1", role: "assistant", content: "<p>甲<br>乙</p>", createdAt: 1 } as ChatMessage);
|
||
ok(count(chatBr, BUBBLE) === 2, "⑨ 回归:chat 模式 `<p>甲<br>乙</p>` 仍是两个气泡");
|
||
ok(count(chatBr, "<br/>") === 0, "⑨ 回归:chat 模式不产出换行元素(`<br>` 是分隔符,不是换行)");
|
||
|
||
// 非 chat 模式走**真实入口**(SplitChatBubble → MessageBubble → modeBody)同样是换行。
|
||
const docsCard = render({ id: "c2", role: "assistant", mode: "docs", content: "甲<br>乙", createdAt: 2 } as ChatMessage);
|
||
ok(count(docsCard, "<br/>") === 1, "⑨ 非 chat 模式经 SplitChatBubble 也是换行");
|
||
ok(has(docsCard, "<span>甲<br/>乙</span>"), "⑨ …且两侧文本在同一个正文节点里(没有切成多气泡)");
|
||
ok(!has(docsCard, "<br"), "⑨ 非 chat 模式的字面 `<br>` 彻底消失");
|
||
}
|
||
|
||
console.log(`inline markup render test: ${checks} checks passed`);
|