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:
1 parent
6ac214366e
commit
a19d12b228
7 files changed
+646
-60
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user