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
@@ -0,0 +1,111 @@
|
||||
// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
|
||||
//
|
||||
// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `<p>`(契约 §4.16,
|
||||
// 由 `splitParagraphs` 处理),动作/旁白用 `<action>`/`<narration>`(由 `splitBubble`
|
||||
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
|
||||
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `<b>` 直接显示给用户。
|
||||
//
|
||||
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
|
||||
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
|
||||
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
|
||||
// 对文本节点默认转义,天然安全。规则:
|
||||
// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `<p>` 机制表达);
|
||||
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
|
||||
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `<script>`,不丢字符);
|
||||
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
|
||||
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
|
||||
// ⑤ 大小写不敏感(`<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 });
|
||||
}
|
||||
Reference in new issue
Block a user