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

+43 -5
View File
@@ -1,11 +1,49 @@
import type { ReactNode } from "react";
import { Fragment, type ReactNode } from "react";
import { parseContent } from "../lib/parseContent";
import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup";
/** Render a message body into its plain-text segments only. `<action>` /
* `<narration>` blocks are NOT rendered inline here — they are peeled out by
* `splitBubble` and shown as attachment notes outside the bubble. */
/** 渲染助手消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由
* `splitBubble` 剥出、以气泡外的注记呈现;剩下的文本段落交给行内白名单渲染。
*
* 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
* (`<script>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。 */
export function renderContent(content: string): ReactNode[] {
return parseContent(content)
.filter((part) => part.type === "text")
.map((part, i) => <span key={i}>{part.text}</span>);
.map((part, i) => <span key={i}>{renderInline(part.text)}</span>);
}
/** 一段(`<p>` 切分后的)段落文本 ⇒ 行内节点序列 ⇒ React 元素。 */
function renderInline(text: string): ReactNode[] {
return parseInlineMarkup(text).map((node, i) => renderInlineNode(node, `n${i}`));
}
/** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名,
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。 */
function renderInlineNode(node: InlineNode, key: string): ReactNode {
if (node.kind === "text") return <Fragment key={key}>{node.text}</Fragment>;
if (node.kind === "break") return <br key={key} />;
const children = node.children.map((child, i) => renderInlineNode(child, `${key}-${i}`));
switch (node.tag) {
case "b":
return <b key={key}>{children}</b>;
case "strong":
return <strong key={key}>{children}</strong>;
case "i":
return <i key={key}>{children}</i>;
case "em":
return <em key={key}>{children}</em>;
case "u":
return <u key={key}>{children}</u>;
case "s":
return <s key={key}>{children}</s>;
case "code":
return (
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
{children}
</code>
);
}
}