feat(web): 消息渲染支持行内标签白名单(修 <b> 等标签裸露显示)
主人 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:
1 parent
a684328b76
commit
2dae4693be
3 files changed
+422
-5
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user