// 行内白名单标签的渲染测试(主人 2026-10-07:「聊天消息里插入了 html/md 标签,能否 // 增加网页前端的基本渲染支持」)。 // // 为什么需要它:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落 `

`(契约 // §4.16)、动作 ``(气泡外灰斜体注记)此前都已处理,**行内**标签(实测 12 条 // assistant 消息里有 6 个 ``)没有处理 ⇒ 用户直接看到字面的 `<b>`。 // 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本 // (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。 // // 分九组: // ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中), // 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/ // `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样; // ② 白名单外(`", "", "块级不做", "

  • 列表不做
  • ", "链接", "", "body{display:none}", " 元素`); ok(seen(src) === src, `② 白名单外的 \`${name}\` 逐字原样显示(不丢字符)`); } ok( seen("") === "", "② `").length === 0, "② 元素树里除最外层 span 外没有任何节点" ); // 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。 for (const e of tree("")) { ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`); } } // ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ──────────────────────────────────── { const broken = [ "x", "日落 18:35 了", "孤立闭标签", "x y", // 开 b 未闭合(i 已闭合)⇒ 整段降级 "x", // 交错(i 未完)⇒ 整段降级 ]; for (const src of broken) { const html = markup(src); ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`); ok(!has(html, "") && !has(html, ""), `③ 降级后不产出元素:${src}`); ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`); } ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签"); ok(seen("a`)不是标签"); ok(seen("x") === "x", "③ §4.16 护栏:`` 不是标签"); ok(seen("文本

    `)不是标签"); } // ── ④ 交错标签:不崩、字符一个不丢 ────────────────────────────────────────────── { const interleaved = "abc"; ok(seen(interleaved) === interleaved, "④ `abc` 逐字保留,不崩"); ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)"); // 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `` 对 // 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。 const mixed = "日落 18:35,还有 未闭合"; ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢"); ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素"); } // ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ──────────────────────────────────────────── { const html = markup("z"); ok(has(html, "z"), "⑤ 只出粗体元素本身"); ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup"); const bEl = tree("z").find((e) => e.type === "b"); ok(Boolean(bEl), "⑤ 树里存在 b 元素"); ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)"); ok(seen("z") === "z", "⑤ 属性被丢弃,正文逐字保留"); const codeHtml = markup("c"); ok(has(codeHtml, "提示") === "提示", "⑤ 引号含空格的属性同样不生效"); } // ── ⑥ 既有 `

    ` / `` 行为无回归(真实气泡链路) ──────────────────────── { const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage); ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记"); // 🔴 **断言变更**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):首段是 // `

    …

    `,注记被剥走后只剩空壳 `

    ` ⇒ 与 `
    ` 碎片同一套 // 口径,**不再产出空气泡**。原断言为 `count(…) === 3`,理由是「首段只有动作注记 ⇒ // 空气泡,与改动前逐字一致」;现为 **2**(就是正文那两段)。 ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ 三个

    里首段被注记剥空 ⇒ 只剩两个气泡(空气泡丢弃)"); // 光数「气泡正文」还不够 —— 空段若仍在,会留下一行「只有头像、没有正文」的空行 // (`ChatBody` 对空正文返回 null ⇒ 空气泡的**壳**没了,但那一行仍在)。所以按**行数**再锁一次。 ok(count(html, "mu-rise-in flex gap-3") === 2, "⑥ 首段空壳连「气泡行」一起消失(不是只把壳画没了)"); ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常"); ok(has(html, "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 渲染为粗体"); ok(!has(html, "<p>") && !has(html, "

    "), "⑥ `

    ` 一个都不进 DOM(段落机制不变)"); ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)"); // 纯 `

    ` 三段 ⇒ 三个气泡(首段 full、其余 compact)。 const three = render({ id: "a2", role: "assistant", content: "

    第一段

    第二段

    第三段

    ", createdAt: 2 } as ChatMessage); ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯

    三段 ⇒ 三个气泡"); ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在"); // 🆕 **空 `

    ` 块也丢**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):与 `
    ` // 碎片同一套口径 —— 上一轮只丢了 `
    ` 碎片,空的 `

    ` 仍留一个空气泡,本轮改掉。 const emptyP = render({ id: "a5", role: "assistant", content: "

    只有这段

    ", createdAt: 5 } as ChatMessage); ok(count(emptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1, "⑥ 空 `

    ` 块丢弃 ⇒ 只剩正文那一个气泡"); ok(count(emptyP, "mu-rise-in flex gap-3") === 1, "⑥ 空 `

    ` 块不留「只有头像」的空行"); const blankP = render({ id: "a6", role: "assistant", content: "

    甲

    乙

    ", createdAt: 6 } as ChatMessage); ok(count(blankP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ `

    `(全空白)同样丢弃 ⇒ 两个气泡"); ok(count(blankP, "mu-rise-in flex gap-3") === 2, "⑥ `

    ` 不留「只有头像」的空行"); const onlyEmptyP = render({ id: "a7", role: "assistant", content: "

    ", createdAt: 7 } as ChatMessage); ok(count(onlyEmptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1 && !has(onlyEmptyP, "<p>"), "⑥ 正文只剩空 `

    ` ⇒ 不画字面量 `

    `,只留空回复外壳(与只剩 `
    ` 同一处置)"); // 🔴 `` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。 const noteWithTag = render({ id: "a3", role: "assistant", content: "她 笑 了

    正文

    ", 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: "帮我看看 这个 和 ", createdAt: 5 } as ChatMessage); ok(has(user, "这个"), "⑥ 用户气泡同样渲染行内标签"); ok(!has(user, "` = 气泡/消息分隔(与 `

    ` 同级,**不是**气泡内换行) ──────────────── // 主人 2026-10-07 口径:「对消息内的 `
    ` 始终处理为多条消息隔断」。这一组全部走 // **真实链路**(`SplitChatBubble` → `splitBubble` → `splitParagraphs` → `renderContent`), // 所以它同时是「上一轮把 `
    ` 当行内换行」那个错误实现的**反向断言**。 { 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("

    甲
    乙

    "); ok(count(inP, BUBBLE) === 2, "⑦ `

    甲
    乙

    ` ⇒ 两个气泡"); ok(has(inP, "甲") && has(inP, "乙"), "⑦ 两个气泡的正文分别是 甲 / 乙"); ok(!has(inP, "
    ") && !has(inP, "<br"), "⑦ `
    ` 既不进 DOM、也不作为字面量出现在正文里"); const noP = asst("甲
    乙"); ok(count(noP, BUBBLE) === 2, "⑦ 无 `

    ` 时 `甲
    乙` ⇒ 两个气泡"); ok(has(noP, "甲") && has(noP, "乙"), "⑦ 无 `

    ` 时同样按 `
    ` 切开"); ok(count(asst("

    甲

    乙

    "), BUBBLE) === 2, "⑦ `

    甲

    乙

    ` ⇒ 两个气泡(中间空气泡丢弃)"); ok(count(asst("

    甲
    乙

    "), BUBBLE) === 2, "⑦ `
    ` 两侧空白 ⇒ 不产生多余空气泡"); ok(count(asst("

    甲

    "), BUBBLE) === 1, "⑦ 尾随 `
    ` 不留空气泡"); ok(count(asst("

    甲

    "), BUBBLE) === 1, "⑦ `
    ` 后全空白不留空气泡"); // 正文只剩 `
    `:气泡分隔把它切光 ⇒ 按空正文交下去(空气泡丢弃,别把字面量画出来)。 const onlyBr = asst("
    "); ok(count(onlyBr, BUBBLE) === 1 && !has(onlyBr, "<br"), "⑦ 正文只剩 `
    ` ⇒ 不产生字面量、只留空回复外壳"); // 三种写法等价(HTML5):`
    ` / `
    ` / `
    `;属性不解析(`
    ` 照样切)。 for (const src of ["

    甲
    乙

    ", "

    甲
    乙

    ", "

    甲
    乙

    ", "

    甲
    乙

    "]) { ok(count(asst(src), BUBBLE) === 2, `⑦ 等价写法都切成两个气泡:${src}`); } // 护栏:`` / `` 不是 br 标签(与 `1 < 2`、`a` 同一条口径)。 const brow = asst("

    甲乙丙

    "); ok(count(brow, BUBBLE) === 1, "⑦ `` 不是 br ⇒ 不切气泡"); ok(has(brow, "<brow>"), "⑦ `` 按白名单外口径逐字原样"); ok(count(asst("

    甲乙丙

    "), BUBBLE) === 1, "⑦ `` / `` 同样不是 br"); // 流式半截 ``)⇒ 不是分隔符,本段仍是一个气泡。 const partial = asst("

    甲"); ok(count(partial, BUBBLE) === 1, "⑦ 流式半截 ``)不切气泡"); ok(has(partial, "<br"), "⑦ 半截标签按字面量显示(字符不丢)"); // 与行内标签混用:切开的两段各自照常做行内渲染。 const mixed = asst("

    日落 18:35
    晚安

    "); ok(count(mixed, BUBBLE) === 2, "⑦ 含行内标签的段落同样按 `
    ` 切开"); ok(has(mixed, "18:35") && has(mixed, "晚安"), "⑦ 两段各自渲染(第一段保留粗体)"); // 用户气泡走同一条链。 const userBr = render({ id: "u2", role: "user", content: "

    甲
    乙

    ", createdAt: 2 } as ChatMessage); ok(count(userBr, "mu-rise-in flex flex-col items-end gap-0.5") === 2, "⑦ 用户气泡同样按 `
    ` 切成两个"); // `` 注记不受 `
    ` 影响(注记文案原样、仍在气泡外),正文照常切。 const noteBr = asst("她 笑 了

    甲
    乙

    "); 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 = {}) => render({ id: "z1", role: "assistant", content, createdAt: 1, ...extra } as ChatMessage); // `

    ` 包的纯注记回复:注记被剥走后只剩空壳 `

    ` ⇒ **零气泡** // (上一轮这里还是 1 个空气泡;本轮口径变更的边界就在这里)。 const pureP = asst("

    她点点头

    "); ok(count(pureP, BUBBLE) === 0, "⑧ `

    …

    ` ⇒ 零个气泡"); ok(has(pureP, "*动作:她点点头*"), "⑧ 注记照常显示(没连注记一起丢)"); ok(!has(pureP, "<p>") && !has(pureP, "<action>"), "⑧ 零气泡时也不留任何字面标签"); // 不带 `

    ` 的纯注记回复(更早的形态):零气泡是**既有行为**,此处锁住不回归。 ok(count(asst("她点点头"), BUBBLE) === 0, "⑧ 无 `

    ` 的纯注记回复同样零气泡(既有行为不变)"); ok(count(asst("*她点点头*"), BUBBLE) === 0, "⑧ 全角星号形态的纯注记回复同样零气泡"); // 🔴 下游核实:零气泡不等于「什么都不画」—— 思维链 / 工具卡 / 心情是**用户可见内容**, // 不能跟着空气泡一起消失,所以补一行**无正文**的外壳(正文本就是空的 ⇒ 没有空气泡)。 const withChrome = asst("

    她查了下天气

    ", { 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("

    她正要说

    ", { streaming: true }); ok(count(streamingNotes, BUBBLE) === 1 && has(streamingNotes, "生成中…"), "⑧ 零气泡 + 流式中给「生成中…」反馈(外壳非空)"); // 零气泡 + 只有尾注记(注记在正文之后)同样不崩。 const tailOnly = asst("

    先说这句

    然后她笑了

    "); ok(count(tailOnly, BUBBLE) === 1 && has(tailOnly, "*动作:然后她笑了*"), "⑧ 尾注记 + 空壳段 ⇒ 一个正文气泡 + 一条注记"); } // ── ⑨ 非 chat 单卡正文:`
    ` ⇒ 换行、行内出样式、白名单外仍字面 ────────────────── // 主人 2026-10-07 晚:「非聊天模式按照应当有的专业卡片和格式来显示」。这些界面是**单卡 // 正文**(没有「多条气泡」的概念)⇒ `
    ` 只换行、不切分;chat 模式的 `
    ` 分隔口径 // 不受影响(本组末尾有回归断言)。全部走真实的 `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 = "甲
    日落 18:35、下划、划掉、"; 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, "
    ") === 1, `⑨ ${mode}:\`
    \` ⇒ 一个真换行元素`); ok(!has(html, "<br"), `⑨ ${mode}:不再把 \`
    \` 当字面量显示`); ok(has(html, "18:35"), `⑨ ${mode}:行内 \`\` 出粗体`); ok(has(html, "下划"), `⑨ ${mode}:行内 \`\` 出下划线`); ok(!has(html, ""), `⑨ ${mode}:\`\` 仍在白名单外(不产出元素)`); ok(has(html, "<s>划掉</s>"), `⑨ ${mode}:白名单外的 \`\` 仍逐字字面`); ok(has(html, "<script>x</script>"), `⑨ ${mode}:白名单外的 \`