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 });
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// 行内白名单标签的渲染测试(主人 2026-10-07:「聊天消息里插入了 html/md 标签,能否
|
||||
// 增加网页前端的基本渲染支持」)。
|
||||
//
|
||||
// 为什么需要它:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落 `<p>`(契约
|
||||
// §4.16)、动作 `<action>`(气泡外灰斜体注记)此前都已处理,**行内**标签(实测 12 条
|
||||
// assistant 消息里有 6 个 `<b>`)没有处理 ⇒ 用户直接看到字面的 `<b>`。
|
||||
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
|
||||
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
|
||||
//
|
||||
// 分六组:
|
||||
// ① 白名单标签渲染成真元素(不是字面量);
|
||||
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
|
||||
// 树里不出现这些元素、不出现任何来自产物的属性;
|
||||
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
|
||||
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
|
||||
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
|
||||
// ⑥ 既有 `<p>` / `<action>` 行为无回归(走真实的 SplitChatBubble 链路)。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/renderContent.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-inline-markup.test.cjs \
|
||||
// && node /tmp/cyrene-inline-markup.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { createElement, Fragment, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { renderContent } from "./renderContent";
|
||||
import { SplitChatBubble } from "../components/chat";
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(html: string, needle: string): boolean {
|
||||
return html.includes(needle);
|
||||
}
|
||||
|
||||
function count(html: string, needle: string): number {
|
||||
return html.split(needle).length - 1;
|
||||
}
|
||||
|
||||
/** 渲染一段(`<p>` 切分后的)段落文本,取静态 HTML。 */
|
||||
function markup(text: string): string {
|
||||
return renderToStaticMarkup(createElement(Fragment, null, renderContent(text)));
|
||||
}
|
||||
|
||||
/** 还原成「用户实际看见的字」:先剥掉**真元素**的标记(含 React 的相邻文本分隔注释),
|
||||
* 再还原实体。顺序不能反 —— 先解码再剥标签会把降级后的字面量 `<b>` 也当成标签误删。 */
|
||||
function decode(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
/** 渲染后**用户实际看见的字**。 */
|
||||
function seen(text: string): string {
|
||||
return decode(markup(text));
|
||||
}
|
||||
|
||||
interface ElInfo {
|
||||
type: string;
|
||||
propKeys: string[];
|
||||
}
|
||||
|
||||
/** 走一遍 `renderContent` 产出的 React 元素树,列出「元素类型 + 非 children 的属性名」。
|
||||
* 这是「有没有不该出现的节点 / 属性」最直接的一层断言 —— 看的是树,不是字符串。 */
|
||||
function tree(text: string): ElInfo[] {
|
||||
const out: ElInfo[] = [];
|
||||
const walk = (node: ReactNode): void => {
|
||||
if (node === null || node === undefined || typeof node !== "object") return;
|
||||
if (Array.isArray(node)) {
|
||||
(node as ReactNode[]).forEach(walk);
|
||||
return;
|
||||
}
|
||||
const el = node as unknown as { type: unknown; props?: Record<string, unknown> };
|
||||
const kind = el.type;
|
||||
const name =
|
||||
typeof kind === "string"
|
||||
? kind
|
||||
: ((kind as { displayName?: string; name?: string } | null)?.displayName ??
|
||||
(kind as { name?: string } | null)?.name ??
|
||||
"Fragment");
|
||||
const props = el.props ?? {};
|
||||
out.push({ type: name, propKeys: Object.keys(props).filter((k) => k !== "children") });
|
||||
walk(props.children as ReactNode);
|
||||
};
|
||||
walk(renderContent(text));
|
||||
return out;
|
||||
}
|
||||
|
||||
function elementTypes(text: string): string[] {
|
||||
return tree(text).map((e) => e.type);
|
||||
}
|
||||
|
||||
/** 除最外层 span 与承载纯文本的 Fragment 之外的节点类型 —— 应当为空,即「没有造出
|
||||
* 任何额外节点」(Fragment 是纯文本的载体,不产生 DOM)。 */
|
||||
function foreignTypes(text: string): string[] {
|
||||
return elementTypes(text).filter((t) => t !== "span" && t !== "Fragment");
|
||||
}
|
||||
|
||||
function render(message: ChatMessage): string {
|
||||
return renderToStaticMarkup(createElement(SplitChatBubble, { message }));
|
||||
}
|
||||
|
||||
const REAL_SAMPLE =
|
||||
"<p><action>赶紧凑过来看你那边,手指在空气里划了下</action></p>\n" +
|
||||
"<p>花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪</p>\n" +
|
||||
"<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>";
|
||||
|
||||
// ── ① 白名单标签渲染成真元素 ────────────────────────────────────────────────────
|
||||
{
|
||||
const html = markup("日落 <b>18:35</b> 了");
|
||||
ok(has(html, "<b>18:35</b>"), "① `<b>` 渲染成粗体元素");
|
||||
ok(!has(html, "<b>"), "① 不再把 `<b>` 当字面量显示");
|
||||
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
|
||||
|
||||
const tags: [string, string][] = [
|
||||
["<strong>粗</strong>", "<strong>粗</strong>"],
|
||||
["<i>斜</i>", "<i>斜</i>"],
|
||||
["<em>强调</em>", "<em>强调</em>"],
|
||||
["<u>下划</u>", "<u>下划</u>"],
|
||||
["<s>划掉</s>", "<s>划掉</s>"],
|
||||
];
|
||||
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
|
||||
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
|
||||
ok(seen("<code>go build</code>") === "go build", "① code 内文本逐字保留");
|
||||
|
||||
// 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
|
||||
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
|
||||
ok(has(markup("<b>x</b><BR>y"), "<br/>"), "① `<BR>` 也认(标签名大小写不敏感)");
|
||||
|
||||
// br 是 void 元素:`<br>` / `<br/>` / `</br>`(HTML5 等价写法)都出换行,两侧文本不丢。
|
||||
ok(seen("上<br>下") === "上下" && has(markup("上<br/>下"), "<br/>"), "① `<br>` / `<br/>` 出换行");
|
||||
ok(has(markup("上</br>下"), "<br/>"), "① `</br>` 按 HTML5 口径等同 `<br>`");
|
||||
|
||||
// 嵌套白名单标签(正常闭合)照常渲染。
|
||||
const nested = markup("<b>外 <i>内</i></b>");
|
||||
ok(has(nested, "<b>外 <i>内</i></b>"), "① 正常嵌套逐层渲染");
|
||||
}
|
||||
|
||||
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
|
||||
{
|
||||
const cases: [string, string, string][] = [
|
||||
["<script>alert(1)</script>", "<script", "script"],
|
||||
["<img src=x onerror=alert(1)>", "<img", "img"],
|
||||
["<div>块级不做</div>", "<div", "div"],
|
||||
["<ul><li>列表不做</li></ul>", "<ul", "ul"],
|
||||
["<a href=\"javascript:alert(1)\">链接</a>", "<a ", "a"],
|
||||
["<iframe src=\"https://x/\"></iframe>", "<iframe", "iframe"],
|
||||
["<style>body{display:none}</style>", "<style", "style"],
|
||||
];
|
||||
for (const [src, forbidden, name] of cases) {
|
||||
ok(!has(markup(src), forbidden), `② 不产出 <${name}> 元素`);
|
||||
ok(seen(src) === src, `② 白名单外的 \`${name}\` 逐字原样显示(不丢字符)`);
|
||||
}
|
||||
ok(
|
||||
seen("<script>alert(1)</script>") === "<script>alert(1)</script>",
|
||||
"② `<script>` 显示为字面量文本,不是可执行节点"
|
||||
);
|
||||
ok(
|
||||
foreignTypes("<script>alert(1)</script>").length === 0,
|
||||
"② 元素树里除最外层 span 外没有任何节点"
|
||||
);
|
||||
|
||||
// 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。
|
||||
for (const e of tree("<img src=x onerror=alert(1)>")) {
|
||||
ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ────────────────────────────────────
|
||||
{
|
||||
const broken = [
|
||||
"<b>x",
|
||||
"日落 <b>18:35 了",
|
||||
"</b>孤立闭标签",
|
||||
"<b>x <i>y</i>", // 开 b 未闭合(i 已闭合)⇒ 整段降级
|
||||
"<b><i>x</b>", // 交错(i 未完)⇒ 整段降级
|
||||
];
|
||||
for (const src of broken) {
|
||||
const html = markup(src);
|
||||
ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`);
|
||||
ok(!has(html, "<b>") && !has(html, "<i>"), `③ 降级后不产出元素:${src}`);
|
||||
ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`);
|
||||
}
|
||||
ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签");
|
||||
ok(seen("a<b") === "a<b", "③ §4.16 护栏:`a<b`(无 `>`)不是标签");
|
||||
ok(seen("<p2>x</p2>") === "<p2>x</p2>", "③ §4.16 护栏:`<p2>` 不是标签");
|
||||
ok(seen("文本 <p 无尖括号") === "文本 <p 无尖括号", "③ §4.16 护栏:`<p`(无 `>`)不是标签");
|
||||
}
|
||||
|
||||
// ── ④ 交错标签:不崩、字符一个不丢 ──────────────────────────────────────────────
|
||||
{
|
||||
const interleaved = "<b>a<i>b</b>c</i>";
|
||||
ok(seen(interleaved) === interleaved, "④ `<b>a<i>b</b>c</i>` 逐字保留,不崩");
|
||||
ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)");
|
||||
|
||||
// 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `<b>` 对
|
||||
// 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。
|
||||
const mixed = "日落 <b>18:35</b>,还有 <i>未闭合";
|
||||
ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢");
|
||||
ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素");
|
||||
}
|
||||
|
||||
// ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ────────────────────────────────────────────
|
||||
{
|
||||
const html = markup("<b class=x onclick=y>z</b>");
|
||||
ok(has(html, "<b>z</b>"), "⑤ 只出粗体元素本身");
|
||||
ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup");
|
||||
|
||||
const bEl = tree("<b class=x onclick=y>z</b>").find((e) => e.type === "b");
|
||||
ok(Boolean(bEl), "⑤ 树里存在 b 元素");
|
||||
ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)");
|
||||
|
||||
ok(seen("<b class=x onclick=y>z</b>") === "z", "⑤ 属性被丢弃,正文逐字保留");
|
||||
|
||||
const codeHtml = markup("<code data-x=1 style=color:red>c</code>");
|
||||
ok(has(codeHtml, "<code") && !has(codeHtml, "data-x") && !has(codeHtml, "color:red"), "⑤ code 上只保留自己的样式类");
|
||||
|
||||
ok(seen("<b title=\"点我\">提示</b>") === "提示", "⑤ 引号含空格的属性同样不生效");
|
||||
}
|
||||
|
||||
// ── ⑥ 既有 `<p>` / `<action>` 行为无回归(真实气泡链路) ────────────────────────
|
||||
{
|
||||
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, "⑥ 三个 <p> ⇒ 三个气泡(首段只有动作注记 ⇒ 空气泡,与改动前逐字一致)");
|
||||
ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
|
||||
ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体");
|
||||
ok(!has(html, "<p>") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)");
|
||||
ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)");
|
||||
|
||||
// 纯 `<p>` 三段 ⇒ 三个气泡(首段 full、其余 compact)。
|
||||
const three = render({ id: "a2", role: "assistant", content: "<p>第一段</p><p>第二段</p><p>第三段</p>", createdAt: 2 } as ChatMessage);
|
||||
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 <p> 三段 ⇒ 三个气泡");
|
||||
ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
|
||||
|
||||
// 🔴 `<action>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
|
||||
const noteWithTag = render({ id: "a3", role: "assistant", content: "<action>她 <b>笑</b> 了</action><p>正文</p>", 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: "帮我看看 <b>这个</b> 和 <script>x</script>", createdAt: 5 } as ChatMessage);
|
||||
ok(has(user, "<b>这个</b>"), "⑥ 用户气泡同样渲染行内标签");
|
||||
ok(!has(user, "<script") && has(user, "<script>x</script>"), "⑥ 用户气泡里的白名单外标签仍是字面量");
|
||||
}
|
||||
|
||||
console.log(`inline markup render test: ${checks} checks passed`);
|
||||
@@ -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