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

+49 -17
View File
@@ -205,22 +205,44 @@ export function MessageBubble({
return <AssistantBubble message={message} mode={mode} active={active} />;
}
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
* carries no `<p>` wrapper falls back to splitting on `\n\n` runs, and a body
* with neither yields an empty list so the caller handles the empty reply.
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
* unclosed `<p>` simply does not match and stays out until it closes. */
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
/** 把一个已切好的段落再按 `<br>` 切开:`<br>` 与 `<p>` **同级** —— 两边的文本各自
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。空气泡丢弃
* (`<p>甲<br><br>乙</p>` ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡)。
* **不含 `<br>` 的段落逐字返回原样**(空段也原样保留)⇒ 既有 `<p>` 口径一个字节不动。 */
function splitBubbleBreaks(segment: string): string[] {
if (!BR_SEPARATOR_RE.test(segment)) return [segment];
return segment
.split(BR_SEPARATOR_RE)
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
* each. Two peers delimit bubbles: `<p>…</p>` (contract §4.16, one `<p>` = one
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
* in-bubble line break). A body with no `<p>` wrapper falls back to splitting on
* `\n\n` runs; a body with none of the three yields an empty list so the caller
* handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a
* still-streaming, unclosed `<p>` simply does not match and stays out until it
* closes. */
function splitParagraphs(content: string): string[] {
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
// emits plain \n\n runs instead (the model often does), split on those too so
// each logical paragraph becomes its own bubble. `content` here is the
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
// can be cut across; never split on a single \n (that is an in-line break, kept
// inside the paragraph).
// inside the paragraph). Every produced segment then goes through
// `splitBubbleBreaks`, so a <br> inside a paragraph cuts it into several bubbles.
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
if (pSegments.length > 0) return pSegments;
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
// Keep non-<p> remainder as its own paragraph bodies.
const segments: string[] = [];
@@ -228,7 +250,7 @@ function splitParagraphs(content: string): string[] {
if (!part.trim()) continue;
segments.push(part.trim());
}
return segments;
return segments.flatMap(splitBubbleBreaks);
}
/** Small muted attachment note for a single `<action>` / `<narration>` block.
@@ -348,12 +370,14 @@ function UserParaBubble({
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
* plain-text body is split into stacked bubbles (first = full bubble, remainder
* = compact). Any other mode — code / home / work / docs / vision / voice /
* agents / settings — is passed straight through to MessageBubble so its body
* renders untouched: no `<p>` splitting, and its content (which may itself
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
* re-parsed. */
* plain-text body is split into stacked bubbles by `splitParagraphs` — two peers
* delimit a bubble: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` is a
* **message separator**, so it must never render as an in-bubble line break).
* First segment = full bubble, the rest compact. Any other mode — code / home /
* work / docs / vision / voice / agents / settings — is passed straight through
* to MessageBubble so its body renders untouched: no `<p>` / `<br>` splitting, and
* its content (which may itself contain `<p>` / `<action>` / `<narration>` as
* literal text or code) is never re-parsed. */
export function SplitChatBubble({
message,
active = false,
@@ -369,8 +393,16 @@ export function SplitChatBubble({
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
// No visible content: hand off so the empty / still-streaming reply keeps its
// usual chrome (avatar, mood, "生成中…").
return <MessageBubble message={message} active={active} />;
// usual chrome (avatar, mood, "生成中…"). 一处例外:正文只剩 `<br>`(气泡分隔被切
// 光)时**不能**把原文交下去 —— 那会把字面量 `<br>` 画进气泡,等于凭空造一个空气泡。
// 这种情况按「空正文」交下去(外壳照旧),与空气泡丢弃的口径一致。
const collapsedByBreaks = BR_SEPARATOR_RE.test(body);
return (
<MessageBubble
message={collapsedByBreaks ? { ...message, content: "" } : message}
active={active}
/>
);
}
// Group the paragraphs of one reply into a tighter column so intra-reply