主人 2026-10-07 三点裁定:「下划线确实可以保留」「多余的空气泡确实可以丢掉」
「非聊天模式 按照应当有的专业卡片和格式来显示」。
- 白名单回到六项(u 加回,s 仍在外)
- 空 p 块也丢(与 br 碎片同一口径);纯注记回复 ⇒ 零气泡
- 非 chat 模式(8 个单卡正文)给它自己的渲染口径 CARD_RENDER:
br ⇒ 真换行元素(那些容器没有 whitespace-pre-wrap,文本换行会被 CSS 折叠)、
行内白名单正常出样式;WorkMessageBody 的按行切清单、CodeMessageBody 的 pre
等既有较强约定一律保留
- 🔴 发现并修了一个会炸的下游损失:空气泡原本是承载头像/思维链/工具卡/心情/
「生成中…」的那一行,丢气泡会把这些用户可见内容一起丢 ⇒ 加 hasBubbleChrome,
补一行无正文外壳
- 测试 99 -> 189 checks;6 个变异(含「外壳行摘掉 ⇒ 工具卡丢失」)
- 改掉一条既有断言:三个 p 中首段被注记剥空 ⇒ 气泡数由 3 改为 2(并补按行数断言)
133 lines
7.6 KiB
TypeScript
133 lines
7.6 KiB
TypeScript
// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
|
||
//
|
||
// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `<p>`(契约 §4.16,
|
||
// 由 `splitParagraphs` 处理),动作/旁白用 `<action>`/`<narration>`(由 `splitBubble`
|
||
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
|
||
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `<b>` 直接显示给用户。
|
||
//
|
||
// 🔴 **`<br>` 有两种身份,由调用方的 `lineBreaks` 决定**(主人 2026-10-07 口径
|
||
// 「对消息内的 `<br>` 始终处理为多条消息隔断」+ 2026-10-07 晚「非聊天模式按照应当有的
|
||
// 专业卡片和格式来显示」):
|
||
// · **chat 模式(默认,`lineBreaks` 关)**:`<br>` 是**气泡/消息分隔符**,与 `<p>` 同级,
|
||
// 由 `components/chat.tsx` 的 `splitParagraphs`(气泡切分层)切掉;到这里它已不存在。
|
||
// 万一有 `<br>` 走到本模块(注记文本等不切气泡的路径),按**白名单外**处理 ⇒ 逐字原样,
|
||
// 绝不在这里伪造一个「气泡内换行」。
|
||
// · **非 chat 的单卡正文(`lineBreaks` 开)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 /
|
||
// 智能体 / 设置这些界面**没有「多条气泡」的概念** ⇒ `<br>` 只**换行**、不切分,解析成
|
||
// `{kind:"break"}`(渲染为真 `<br/>` 元素,不依赖父容器的 `white-space`)。
|
||
//
|
||
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
|
||
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
|
||
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
|
||
// 对文本节点默认转义,天然安全。规则:
|
||
// ① 只认**行内**白名单元素(六项:`b` / `strong` / `i` / `em` / `code` / `u`),块级一律
|
||
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达);
|
||
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
|
||
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `<script>`,不丢字符);
|
||
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
|
||
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
|
||
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
|
||
|
||
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。主人 2026-10-07 晚先把它收窄为
|
||
* 五项(`u` / `s` 在她百条输出里 0 命中),当日复核后又把 **`u` 加回**(「下划线确实
|
||
* 可以保留」)⇒ 现为 **六项**。`s` **仍在白名单外**(照旧逐字原样显示);`br` 也**不是**
|
||
* 行内元素 —— 它是 void 标签,见文件头(chat 里是气泡分隔符、单卡正文里是换行)。 */
|
||
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u";
|
||
|
||
/** 解析结果:一段文本被拆成的有序节点序列。
|
||
* `break` 只在 `lineBreaks` 打开时产生(非 chat 单卡正文里的一个 `<br>` ⇒ 一次换行)。 */
|
||
export type InlineNode =
|
||
| { kind: "text"; text: string }
|
||
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] }
|
||
| { kind: "break" };
|
||
|
||
/** 词法单元:文本 / 开标签 / 闭标签 / 换行(`<br>`)。 */
|
||
type Token =
|
||
| { kind: "text"; text: string }
|
||
| { kind: "open"; tag: InlineElementTag }
|
||
| { kind: "close"; tag: InlineElementTag }
|
||
| { kind: "break" };
|
||
|
||
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u"]);
|
||
|
||
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
|
||
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
|
||
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g;
|
||
|
||
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。
|
||
* `lineBreaks` 打开时(非 chat 单卡正文)`<br>` 变成 `{kind:"break"}`;关闭时(chat 气泡)
|
||
* 它按白名单外文本处理 —— 因为那里 `<br>` 早被 `splitParagraphs` 当气泡分隔符切掉了。 */
|
||
export function parseInlineMarkup(text: string, lineBreaks = false): InlineNode[] {
|
||
const built = buildTree(tokenize(text, lineBreaks));
|
||
return built ?? [{ kind: "text", text }];
|
||
}
|
||
|
||
/** 切出标签候选,其余按文本切段。非白名单标签连同其尖括号一起作为**文本**单元。 */
|
||
function tokenize(text: string, lineBreaks: boolean): 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, lineBreaks));
|
||
last = m.index + m[0].length;
|
||
}
|
||
if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) });
|
||
return tokens;
|
||
}
|
||
|
||
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
|
||
* `<br>` 在 `lineBreaks` 打开时是换行;关闭时走**白名单外**那一支(chat 模式下它本就被
|
||
* 气泡切分层切走,`components/chat.tsx#BR_SEPARATOR_RE` 用的是同一条标签候选口径:
|
||
* `<br>` / `<br/>` / `</br>` 等价、属性不解析、`<brow>` / `<br2>` 都不是 br)。 */
|
||
function classify(m: RegExpExecArray, lineBreaks: boolean): Token {
|
||
const raw = m[0];
|
||
const name = m[1].toLowerCase();
|
||
if (lineBreaks && 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` 里已被丢弃。
|
||
* `break` 不参与配对(void 标签),只按位置落进当前节点的子序列。 */
|
||
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 });
|
||
}
|