Files
Cyrene_v2/frontend/web/src/lib/inlineMarkup.ts
T
AskaEth f01fd7db20 feat(web): u 加回白名单 + 空气泡丢弃 + 非 chat 模式不再显示字面标签
主人 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(并补按行数断言)
2026-10-07 18:44:45 +08:00

133 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
//
// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `<p>`(契约 §4.16,
// 由 `splitParagraphs` 处理),动作/旁白用 `<action>`/`<narration>`(由 `splitBubble`
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `&lt;b&gt;` 直接显示给用户。
//
// 🔴 **`<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>` 显示成 `&lt;script&gt;`,不丢字符);
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
// ⑤ 大小写不敏感(`<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 });
}