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(并补按行数断言)
This commit is contained in:
AskaEth committed 2026-10-07 18:44:45 +08:00
1 parent 1107b2a717
commit f01fd7db20
5 files changed
+269 -71

No files matched your search

+43 -25
View File
@@ -5,54 +5,65 @@
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `&lt;b&gt;` 直接显示给用户。
//
// 🚫 **`<br>` 不在这里**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为多条消息
// 隔断」):它是**气泡/消息分隔符**,与 `<p>` 同级,由 `components/chat.tsx` 的
// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 `<br>` 走到本模块
// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示,
// 绝不在这里伪造一个「气泡内换行」。
// 🔴 **`<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`),块级一律
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达,`<br>` 由气泡切分层处理);
// ① 只认**行内**白名单元素(六项:`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 命中、不值得各端为它们返工;`br` **不是行内标签**
* (它是气泡分隔符,见文件头),两者从此一律按白名单外处理(逐字原样显示)。 */
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code";
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。主人 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: "element"; tag: InlineElementTag; children: InlineNode[] }
| { kind: "break" };
/** 词法单元:文本 / 开标签 / 闭标签。 */
/** 词法单元:文本 / 开标签 / 闭标签 / 换行(`<br>`)。 */
type Token =
| { kind: "text"; text: string }
| { kind: "open"; tag: InlineElementTag }
| { kind: "close"; tag: InlineElementTag };
| { kind: "close"; tag: InlineElementTag }
| { kind: "break" };
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code"]);
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;
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。 */
export function parseInlineMarkup(text: string): InlineNode[] {
const built = buildTree(tokenize(text));
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。
* `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): Token[] {
function tokenize(text: string, lineBreaks: boolean): Token[] {
const tokens: Token[] = [];
let last = 0;
TAG_RE.lastIndex = 0;
@@ -60,7 +71,7 @@ function tokenize(text: string): Token[] {
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));
tokens.push(classify(m, lineBreaks));
last = m.index + m[0].length;
}
if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) });
@@ -68,11 +79,13 @@ function tokenize(text: string): Token[] {
}
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
* `<br>` 走的就是这一支(它已从白名单移除):到本模块时它只是**字面量文本**,
* 气泡分隔在 `components/chat.tsx` 的 `splitParagraphs` 里就已经切完。 */
function classify(m: RegExpExecArray): Token {
* `<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 };
}
@@ -82,7 +95,8 @@ function isInlineElementTag(name: string): name is InlineElementTag {
}
/** 栈式建树。返回 `null` 表示结构不平衡(落单闭标签 / 交错 / 未闭合)⇒ 调用方整段降级。
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。 */
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。
* `break` 不参与配对(void 标签),只按位置落进当前节点的子序列。 */
function buildTree(tokens: Token[]): InlineNode[] | null {
const root: InlineNode[] = [];
const stack: { tag: InlineElementTag; children: InlineNode[] }[] = [];
@@ -93,6 +107,10 @@ function buildTree(tokens: Token[]): InlineNode[] | null {
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 });