diff --git a/frontend/web/src/lib/inlineMarkup.ts b/frontend/web/src/lib/inlineMarkup.ts new file mode 100644 index 0000000..ea5b4fa --- /dev/null +++ b/frontend/web/src/lib/inlineMarkup.ts @@ -0,0 +1,111 @@ +// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。 +// +// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `

`(契约 §4.16, +// 由 `splitParagraphs` 处理),动作/旁白用 ``/``(由 `splitBubble` +// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `` 之类强调标签。 +// 后两类此前没有处理 ⇒ `18:35` 会把字面量 `<b>` 直接显示给用户。 +// +// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导), +// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。 +// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React +// 对文本节点默认转义,天然安全。规则: +// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `

` 机制表达); +// ② **属性一律丢弃**(`` 只取标签名 `b`,属性绝不进 DOM); +// ③ 白名单外标签**逐字原样**当文本(`", "", "块级不做", "

  • 列表不做
  • ", "链接", "", "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 仍是气泡外的灰斜体注记"); + ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 三个

    ⇒ 三个气泡(首段只有动作注记 ⇒ 空气泡,与改动前逐字一致)"); + 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, "第三段"), "⑥ 三段正文都在"); + + // 🔴 `` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。 + 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, "` / - * `` blocks are NOT rendered inline here — they are peeled out by - * `splitBubble` and shown as attachment notes outside the bubble. */ +/** 渲染助手消息正文。`` / `` 块在这里**不**进入气泡 —— 它们由 + * `splitBubble` 剥出、以气泡外的注记呈现;剩下的文本段落交给行内白名单渲染。 + * + * 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有 + * `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签 + * (`