feat(web): 消息渲染支持行内标签白名单(修 <b> 等标签裸露显示)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 2026-10-07:「她在聊天消息中插入了 html/md 标签,能否增加网页前端
对此类标签的基本渲染支持?」

实测现场(最近 12 条 assistant 消息):<p> 96 次、<action> 34 次
(这两个前端已支持),<b> 6 次(未支持 => 裸标签直接显示给用户);
Markdown 记号 0 个 => 她不用 Markdown,所以不引 Markdown 引擎。

- 新增 inlineMarkup.ts(纯逻辑,111 行):把文本切成「文本 / br / 行内元素」
  的节点树;渲染与解析分离,不拼 HTML
- renderContent.tsx 沿用既有出入口,把节点树映射成 React 元素;
  既有 <p> / <action> 机制原样不动
- 白名单:b / strong / i / em / code / u / s(成对)+ br(void),
  标签名大小写不敏感;不做块级、不做 a(独立安全面)、不做 Markdown
- 安全:全程无 dangerouslySetInnerHTML(文本走 React 子节点,默认转义);
  属性一律丢弃(节点类型里没有属性位);白名单外 => 纯文本字面量;
  结构不平衡(未闭合/交错)=> 整段降级为原文,字符一个不丢
- 测试 76 条断言(esbuild + node,零新依赖);含 3 条安全性质
- 无回归:改动前后 11 个消息样例过真实 SplitChatBubble 链路逐字节对照,
  7 个 SAME,4 个 DIFF 恰是含白名单标签的样例
This commit is contained in:
AskaEth committed 2026-10-07 17:11:37 +08:00
1 parent a684328b76
commit 2dae4693be
3 files changed
+422 -5

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
//
// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `<p>`(契约 §4.16,
// 由 `splitParagraphs` 处理),动作/旁白用 `<action>`/`<narration>`(由 `splitBubble`
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `&lt;b&gt;` 直接显示给用户。
//
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
// 对文本节点默认转义,天然安全。规则:
// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `<p>` 机制表达);
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `&lt;script&gt;`,不丢字符);
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。`br` 是 void 元素,单独处理。 */
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u" | "s";
/** 解析结果:一段文本被拆成的有序节点序列。 */
export type InlineNode =
| { kind: "text"; text: string }
| { kind: "break" }
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] };
/** 词法单元:文本 / 换行(void)/ 开标签 / 闭标签。 */
type Token =
| { kind: "text"; text: string }
| { kind: "break" }
| { kind: "open"; tag: InlineElementTag }
| { kind: "close"; tag: InlineElementTag };
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u", "s"]);
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g;
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。 */
export function parseInlineMarkup(text: string): InlineNode[] {
const built = buildTree(tokenize(text));
return built ?? [{ kind: "text", text }];
}
/** 切出标签候选,其余按文本切段。非白名单标签连同其尖括号一起作为**文本**单元。 */
function tokenize(text: string): Token[] {
const tokens: Token[] = [];
let last = 0;
TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TAG_RE.exec(text)) !== null) {
if (m.index > last) tokens.push({ kind: "text", text: text.slice(last, m.index) });
tokens.push(classify(m));
last = m.index + m[0].length;
}
if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) });
return tokens;
}
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。 */
function classify(m: RegExpExecArray): Token {
const raw = m[0];
const name = m[1].toLowerCase();
// `br` 是 void 元素:`<br>`、`<br/>` 都出换行;`</br>` 按 HTML5 口径等同 `<br>`。
if (name === "br") return { kind: "break" };
if (!isInlineElementTag(name)) return { kind: "text", text: raw };
return raw.charAt(1) === "/" ? { kind: "close", tag: name } : { kind: "open", tag: name };
}
function isInlineElementTag(name: string): name is InlineElementTag {
return INLINE_ELEMENT_TAGS.has(name);
}
/** 栈式建树。返回 `null` 表示结构不平衡(落单闭标签 / 交错 / 未闭合)⇒ 调用方整段降级。
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。 */
function buildTree(tokens: Token[]): InlineNode[] | null {
const root: InlineNode[] = [];
const stack: { tag: InlineElementTag; children: InlineNode[] }[] = [];
const current = (): InlineNode[] => (stack.length > 0 ? stack[stack.length - 1].children : root);
for (const token of tokens) {
if (token.kind === "text") {
appendText(current(), token.text);
continue;
}
if (token.kind === "break") {
current().push({ kind: "break" });
continue;
}
if (token.kind === "open") {
const children: InlineNode[] = [];
current().push({ kind: "element", tag: token.tag, children });
stack.push({ tag: token.tag, children });
continue;
}
const top = stack.pop();
if (!top || top.tag !== token.tag) return null;
}
return stack.length === 0 ? root : null;
}
/** 追加文本并合并相邻文本节点(相邻文本节点在静态渲染里会被插入注释分隔符,
* 合并后输出更干净,也让「降级 = 逐字原样」更容易断言)。 */
function appendText(nodes: InlineNode[], text: string): void {
const last = nodes[nodes.length - 1];
if (last && last.kind === "text") last.text += text;
else nodes.push({ kind: "text", text });
}