feat(markup): br 改为气泡/消息分隔符 + 行内白名单收窄到五项(Web + IM 两侧)

主人 2026-10-07:「对消息内的 <br> 始终处理为多条消息隔断」。

Web 侧(frontend/web):
- chat.tsx 的 splitParagraphs(SplitChatBubble 的切分层)新增 BR_SEPARATOR_RE
  与 splitBubbleBreaks:<br>/<br/>/</br> 等价、属性不解析、<brow>/<br2> 挡掉、
  流式半截 <br 不切;不含 <br> 的段逐字返回原样(既有 p 口径一字节不动)
- inlineMarkup.ts 删掉 break 节点(br 不再是行内元素),白名单收窄为
  b/strong/i/em/code 五项;renderContent.tsx 同步;测试 76 -> 99 checks
- 40 条基线逐字节对照:28 IDENTICAL,12 个 DIFF 恰是含 br/u/s/<BR> 的样例

IM 侧(internal/services/channelbridge):
- 新增 inline_tags.go:成对平衡的白名单行内标签剥标签留文本、白名单外与
  结构不平衡整段原样保留;br 作为第二级分隔符交给 splitBubbles
- 两级切分:p 块为第一级,块内按 br 再切为第二级;空气泡丢弃
- br 切分放在角色注记摘除之后:否则 action 里的 br 会把注记正文漏成聊天气泡
  (变异专门钉住这条顺序)

两路各 4 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
This commit is contained in:
AskaEth committed 2026-10-07 17:32:05 +08:00
1 parent 6ac214366e
commit a19d12b228
7 files changed
+646 -60

No files matched your search

+17 -14
View File
@@ -5,34 +5,41 @@
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `&lt;b&gt;` 直接显示给用户。
//
// 🚫 **`<br>` 不在这里**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为多条消息
// 隔断」):它是**气泡/消息分隔符**,与 `<p>` 同级,由 `components/chat.tsx` 的
// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 `<br>` 走到本模块
// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示,
// 绝不在这里伪造一个「气泡内换行」。
//
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
// 对文本节点默认转义,天然安全。规则:
// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `<p>` 机制表达);
// ① 只认**行内**白名单标签(五项:`b` / `strong` / `i` / `em` / `code`),块级一律
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达,`<br>` 由气泡切分层处理);
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `&lt;script&gt;`,不丢字符);
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。`br` 是 void 元素,单独处理。 */
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u" | "s";
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。白名单经主人 2026-10-07 晚收窄为
* **五项**:`u` / `s` 在她百条输出里 0 命中、不值得各端为它们返工;`br` **不是行内标签**
* (它是气泡分隔符,见文件头),两者从此一律按白名单外处理(逐字原样显示)。 */
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code";
/** 解析结果:一段文本被拆成的有序节点序列。 */
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"]);
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code"]);
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
@@ -60,12 +67,12 @@ function tokenize(text: string): Token[] {
return tokens;
}
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。 */
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
* `<br>` 走的就是这一支(它已从白名单移除):到本模块时它只是**字面量文本**,
* 气泡分隔在 `components/chat.tsx` 的 `splitParagraphs` 里就已经切完。 */
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 };
}
@@ -86,10 +93,6 @@ 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 });