diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx
index 37e49f7..186b9cc 100644
--- a/frontend/web/src/components/chat.tsx
+++ b/frontend/web/src/components/chat.tsx
@@ -205,22 +205,44 @@ export function MessageBubble({
return
`-delimited paragraph - * bodies. Each complete `
…
` block becomes one bubble segment; a body that - * carries no `` 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 `
` simply does not match and stays out until it closes. */
+/** ` ` **同级** —— 两边的文本各自
+ * 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。空气泡丢弃
+ * (` 甲 ` 口径一个字节不动。 */
+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: ` … ` = one
+ * bubble) and ` ` 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 ` ` simply does not match and stays out until it
+ * closes. */
function splitParagraphs(content: string): string[] {
// Preferred: the agent wraps each paragraph in … ]*>([\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- 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 ` ` splitting, and its content (which may itself
- * contain ` ` / ` … ` / ` ` / ` ` 同级,由 `components/chat.tsx` 的
+// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 ` ` 机制表达);
+// ① 只认**行内**白名单标签(五项:`b` / `strong` / `i` / `em` / `code`),块级一律
+// 不支持(段落仍由 §4.16 的 ` ` 机制表达,` 你好呀 今天天气不错 a b 真乖♪ ` 一个气泡、空气泡丢弃、无 ` ` 且无 ` 第一段 粗 第二段 正文 今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。 第二段 ` 段落标记、折叠连续空行、
+// 剥掉行内白名单标签。这三步必须留在切分**之后**:段落与空行折叠是「一条消息内部」
+// 的排版整理,行内标签的「整段降级」也以气泡为粒度(与 Web 端同口径)。
+func finishNarration(segment string) string {
+ s := paragraphRe.ReplaceAllString(segment, "")
s = blankLinesRe.ReplaceAllString(s, "\n")
+ s = stripInlineTags(s)
return strings.TrimSpace(s)
}
@@ -93,31 +110,72 @@ func hasRoleplayPrefix(t string) bool {
var paragraphBlockRe = regexp.MustCompile(`(?is) ]*?>(.*?) ... 标记的回复按
-// 单个气泡处理(保留换行)。纯动作/独白得到空切片。
+//
+// **两个同级的分隔符**(契约 §6 v0.89):
+// - 每个 ` ... ` 同级 —— **不是**一个换行字符。
+//
+// 空气泡(含只由空白构成的气泡)被丢弃。没有 ` ` 标记的回复**不是**按单个气泡处理
+// 了(这一条被 v0.89 改写):它同样会按 ` 标记但没有格式良好的块——回退为整段文本。
- if c := stripNarration(text); c != "" {
+ out = append(out, bubblesOfSegment(text)...)
+ }
+ return out
+}
+
+// bubblesOfSegment 把一个**段落区段**(` ` 块内部文本,或无 ` ` 时的整段文本)
+// 切成它承载的若干气泡文本。
+//
+// 🔴 两步顺序是本文件的要害:
+// 1. 先 `stripRoleplayMarkup` —— 角色扮演/星号标记整块消失。因此 ` ` / 折叠空行 / 剥行内标签),
+// 因此「空气泡丢弃」与 ` ` 空气泡同一套口径:只由空白构成的段会被 TrimSpace 成空串后丢掉。
+func bubblesOfSegment(segment string) []string {
+ parts := splitByBreakTags(stripRoleplayMarkup(segment))
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if c := finishNarration(p); c != "" {
out = append(out, c)
}
}
return out
}
+// splitByBreakTags 只按 `
` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `
` 始终处理为多条
+ * 消息隔断」)。`
` / `
` / `` 三种写法等价(HTML5 口径),属性不解析
+ * (`
` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
+ * —— `
` 不算标签)。 */
+const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
+
+/** 把一个已切好的段落再按 `
` 切开:`
` 与 `
乙
` 两侧都是空白 ⇒ 不产生多余气泡)。
+ * **不含 `
` 的段落逐字返回原样**(空段也原样保留)⇒ 既有 `
` (主人 2026-10-07: `
` is a message separator, never an
+ * in-bubble line break). A body with no `
inside a paragraph cuts it into several bubbles.
const pRe = /
` (主人 2026-10-07: a `
` 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 `
` splitting, and
+ * its content (which may itself contain `
`(气泡分隔被切
+ // 光)时**不能**把原文交下去 —— 那会把字面量 `
` 画进气泡,等于凭空造一个空气泡。
+ // 这种情况按「空正文」交下去(外壳照旧),与空气泡丢弃的口径一致。
+ const collapsedByBreaks = BR_SEPARATOR_RE.test(body);
+ return (
+
` 不在这里**(主人 2026-10-07 口径「对消息内的 `
` 始终处理为多条消息
+// 隔断」):它是**气泡/消息分隔符**,与 `
` 走到本模块
+// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示,
+// 绝不在这里伪造一个「气泡内换行」。
+//
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
// 对文本节点默认转义,天然安全。规则:
-// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `
` 由气泡切分层处理);
// ② **属性一律丢弃**(`` 只取标签名 `b`,属性绝不进 DOM);
// ③ 白名单外标签**逐字原样**当文本(`",
+ "",
+ "",
+ "下划线",
+ "
删除线",
+ "大写也不认",
+ }
+ for _, in := range cases {
+ if got := stripNarration(in); got != in {
+ t.Errorf("stripNarration(%q) = %q, want verbatim", in, got)
+ }
+ }
+ // 白名单与外标签混排:只动白名单那对,其余逐字保留。
+ in := "她说了 这样 和 两回事"
+ want := "她说了 这样 和 两回事"
+ if got := stripNarration(in); got != want {
+ t.Errorf("stripNarration(%q) = %q, want %q", in, got, want)
+ }
+}
+
+// TestInlineTags_UnbalancedDegradesWholeSegment 结构不平衡(未闭合 / 落单闭标签 /
+// 交错)⇒ 整段保持原样(契约把粒度列为实现细节;本端与 Web 端取同一粒度)。
+func TestInlineTags_UnbalancedDegradesWholeSegment(t *testing.T) {
+ t.Parallel()
+ cases := []string{
+ "未闭合 x",
+ "落单闭标签 x",
+ "交错 x",
+ "自闭合 x",
+ "ac",
+ "没收尾",
+ "混排 开了 也开了 只剩 b 未闭合",
+ }
+ for _, in := range cases {
+ if got := stripNarration(in); got != in {
+ t.Errorf("stripNarration(%q) = %q, want verbatim (unbalanced)", in, got)
+ }
+ }
+}
+
+// TestInlineTags_AttributesDropped 属性一律不进结果。
+func TestInlineTags_AttributesDropped(t *testing.T) {
+ t.Parallel()
+ in := "答案 y"
+ if got := stripNarration(in); got != "答案 y" {
+ t.Errorf("stripNarration(%q) = %q, want %q", in, got, "答案 y")
+ }
+ in2 := `ok`
+ if got := stripNarration(in2); got != "ok" {
+ t.Errorf("stripNarration(%q) = %q, want %q", in2, got, "ok")
+ }
+}
+
+// TestInlineTags_LookalikesNotTreatedAsTags 标签候选的护栏(契约 §4.16 同一口径):
+// 不是标签候选的形状一律原样保留。
+func TestInlineTags_LookalikesNotTreatedAsTags(t *testing.T) {
+ t.Parallel()
+ cases := []string{
+ "1 < 2 且 3 > 2",
+ "a不是段落标签",
+ "弯起眼睛笑了
` 时整段一个气泡。
+func TestInlineTags_NoRegression_BubbleSplitting(t *testing.T) {
+ t.Parallel()
+ if got := splitBubbles("
` 是否真的变成了多条消息。
+func TestInlineTags_EndToEndThroughRealPipeline(t *testing.T) {
+ t.Parallel()
+ const reply = "
第三段:go test 跑过了
` 的分隔符切分(见 bubblesOfSegment)。
func stripNarration(text string) string {
if strings.TrimSpace(text) == "" {
return ""
}
+ return finishNarration(stripRoleplayMarkup(text))
+}
+
+// stripRoleplayMarkup 摘除角色扮演/动作/旁白标记:成对与落单的
+// `
` 类分隔符(`
` / `
` / ``,大小写不敏感)= 一次
+// 「切成多条消息」的边界,与 `
` 切开,例如 `甲
乙` ⇒ 两条消息。
+// 纯动作/独白得到空切片。
func splitBubbles(text string) []string {
if !strings.Contains(strings.ToLower(text), "
` 写在
+// `
` 切分 —— 此时剩下的 `
` 都只可能出现在**真正要对用户说的正文**里,
+// 切开正合契约「始终处理为多条消息隔断」。
+//
+// 切分**之后**的每一段各自走 finishNarration(抹平 `
` 类分隔符切分(分隔符本身丢弃),不做任何清洗。
+// 没有分隔符时返回单元素切片,即「整段一个气泡」的既有语义。
+func splitByBreakTags(segment string) []string {
+ spans := breakSeparatorSpans(segment)
+ if len(spans) == 0 {
+ return []string{segment}
+ }
+ parts := make([]string, 0, len(spans)+1)
+ last := 0
+ for _, sp := range spans {
+ parts = append(parts, segment[last:sp[0]])
+ last = sp[1]
+ }
+ return append(parts, segment[last:])
+}
+
// bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时,
// 使连发段落不会刷屏(design/17 §2)。Web 前端
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。