`-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 的 ` ` 机制表达,`
` 的**气泡分隔**判据(主人 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
没收尾",
+ "混排 开了 也开了 只剩 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不是段落标签",
+ "弯起眼睛笑了\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"},
+ {"你好呀
\n心里有点甜 \n今天天气不错
", "你好呀\n今天天气不错"},
+ {"就是普通一句话,没有动作。\n第二行。", "就是普通一句话,没有动作。\n第二行。"},
+ {"拍拍你 ", ""},
+ {"*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"},
+ {"*旁白:心里有点甜*\n这是正文。", "这是正文。"},
+ {"这是一段 *强调* 文字,不是动作。", "这是一段 *强调* 文字,不是动作。"},
+ {"*弯起眼睛*", ""},
+ {"a
\n\n\nb
", "a\nb"},
+ {"满意地点点头
\n真乖♪
", "真乖♪"},
+ }
+ for _, c := range cases {
+ if got := stripNarration(c.in); got != c.want {
+ t.Errorf("stripNarration(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+ // 注记被剥掉后,注记内部的行内标签也不该参与渲染(契约 §6 v0.89 末条)。
+ in := "她 点点头 好呀。"
+ if got := stripNarration(in); got != "好呀。" {
+ t.Errorf("stripNarration(%q) = %q, want %q", in, got, "好呀。")
+ }
+}
+
+// TestInlineTags_NoRegression_BubbleSplitting 气泡切分语义不受影响:
+// 一个 `` 一个气泡、空气泡丢弃、无 `
` 且无 `
` 时整段一个气泡。
+func TestInlineTags_NoRegression_BubbleSplitting(t *testing.T) {
+ t.Parallel()
+ if got := splitBubbles("
第一段 粗
第二段
"); len(got) != 2 || got[0] != "第一段 粗" || got[1] != "第二段" {
+ t.Errorf("splitBubbles = %#v", got)
+ }
+ if got := splitBubbles("点点头
正文
"); len(got) != 1 || got[0] != "正文" {
+ t.Errorf("splitBubbles = %#v", got)
+ }
+ in := "无段落标记的一段\n带 粗 的行"
+ if got := splitBubbles(in); len(got) != 1 || got[0] != "无段落标记的一段\n带 粗 的行" {
+ t.Errorf("splitBubbles = %#v", got)
+ }
+ if got := splitBubbles("拍拍你 "); len(got) != 0 {
+ t.Errorf("splitBubbles = %#v, want empty", got)
+ }
+}
+
+// TestInlineTags_EndToEndThroughRealPipeline 走**真出站链路**:入站消息 → 会话 /
+// actor → agent 循环(模型侧是桩)→ 清洗切分 → 适配器出站。断言的正是适配器
+// 真正收到的那条消息内容与**条数** —— 也就是用户在微信/QQ 上会看到的字面文本,
+// 以及 `
` 是否真的变成了多条消息。
+func TestInlineTags_EndToEndThroughRealPipeline(t *testing.T) {
+ t.Parallel()
+ const reply = "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。
" +
+ "第二段
第三段:go test 跑过了
"
+ chat := &stubChatter{res: &loop.Result{Text: reply}}
+ sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
+ ad := &fakeAdapter{name: "telegram"}
+ b, err := New(Options{
+ Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ b.Add(ad)
+ b.bubbleGapFn = func() time.Duration { return 0 }
+
+ b.process(context.Background(), directMessage())
+
+ want := []string{
+ "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。",
+ "第二段",
+ "第三段:go test 跑过了",
+ }
+ if len(ad.sends) != len(want) {
+ t.Fatalf("出站气泡 = %d 条, want %d: %+v", len(ad.sends), len(want), ad.sends)
+ }
+ for i, out := range ad.sends {
+ if out.Content != want[i] {
+ t.Fatalf("气泡[%d] 内容 = %q, want %q", i, out.Content, want[i])
+ }
+ if strings.ContainsAny(out.Content, "<>") {
+ t.Fatalf("气泡[%d] 仍带标签: %q", i, out.Content)
+ }
+ if out.ContentType != adapter.ContentText {
+ t.Fatalf("气泡[%d] ContentType = %q, want text", i, out.ContentType)
+ }
+ }
+}
diff --git a/internal/services/channelbridge/sanitize.go b/internal/services/channelbridge/sanitize.go
index 3d00625..97ecf48 100644
--- a/internal/services/channelbridge/sanitize.go
+++ b/internal/services/channelbridge/sanitize.go
@@ -41,13 +41,23 @@ var (
const shortRoleplayRunes = 40
// stripNarration 返回去掉角色扮演/动作/
-// 旁白标记、段落标记被抹平、连续空行被折叠后的
+// 旁白标记、段落标记被抹平、连续空行被折叠、行内白名单标签被剥掉的
// 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的
// 回复会变成空字符串(调用方可据此跳过发送)。
+//
+// 它是 finishNarration ∘ stripRoleplayMarkup 两步的组合 —— 拆成两步是为了让
+// splitBubbles 能在中间插入 `
` 的分隔符切分(见 bubblesOfSegment)。
func stripNarration(text string) string {
if strings.TrimSpace(text) == "" {
return ""
}
+ return finishNarration(stripRoleplayMarkup(text))
+}
+
+// stripRoleplayMarkup 摘除角色扮演/动作/旁白标记:成对与落单的
+// ``/``、全角星号块、带角色扮演关键词的半角星号块。
+// 顺序与参数逐字沿用既有实现(本函数只是把原来的前四步搬出来,行为不变)。
+func stripRoleplayMarkup(text string) string {
s := pairedRoleplayRe.ReplaceAllString(text, "")
s = strayRoleplayRe.ReplaceAllString(s, "")
s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string {
@@ -56,9 +66,16 @@ func stripNarration(text string) string {
}
return m
})
- s = halfStarRoleplayRe.ReplaceAllString(s, "")
- s = paragraphRe.ReplaceAllString(s, "")
+ return halfStarRoleplayRe.ReplaceAllString(s, "")
+}
+
+// finishNarration 完成一段**气泡文本**的清洗:抹平 `` 段落标记、折叠连续空行、
+// 剥掉行内白名单标签。这三步必须留在切分**之后**:段落与空行折叠是「一条消息内部」
+// 的排版整理,行内标签的「整段降级」也以气泡为粒度(与 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)
]*?>(.*?)
`)
// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。
-// 每个 ...
块为一个气泡,其文本已剥离角色扮演/动作/
-// 旁白;空气泡被丢弃。没有 标记的回复按
-// 单个气泡处理(保留换行)。纯动作/独白得到空切片。
+//
+// **两个同级的分隔符**(契约 §6 v0.89):
+// - 每个 `
...
` 块 = 一个气泡(既有语义,逐字不变);
+// - 每个 `
` 类分隔符(`
` / `
` / ``,大小写不敏感)= 一次
+// 「切成多条消息」的边界,与 `` 同级 —— **不是**一个换行字符。
+//
+// 空气泡(含只由空白构成的气泡)被丢弃。没有 `
` 标记的回复**不是**按单个气泡处理
+// 了(这一条被 v0.89 改写):它同样会按 `
` 切开,例如 `甲
乙` ⇒ 两条消息。
+// 纯动作/独白得到空切片。
func splitBubbles(text string) []string {
if !strings.Contains(strings.ToLower(text), "
标记但没有格式良好的块——回退为整段文本。
- if c := stripNarration(text); c != "" {
+ out = append(out, bubblesOfSegment(text)...)
+ }
+ return out
+}
+
+// bubblesOfSegment 把一个**段落区段**(`
` 块内部文本,或无 `
` 时的整段文本)
+// 切成它承载的若干气泡文本。
+//
+// 🔴 两步顺序是本文件的要害:
+// 1. 先 `stripRoleplayMarkup` —— 角色扮演/星号标记整块消失。因此 `
` 写在
+// `` / 星号块**内部**时不会把一段注记劈成两条聊天消息(那样会把注记正文
+// 漏成聊天气泡,是对既有「注记整体剥除」口径的回归);
+// 2. 再按 `
` 切分 —— 此时剩下的 `
` 都只可能出现在**真正要对用户说的正文**里,
+// 切开正合契约「始终处理为多条消息隔断」。
+//
+// 切分**之后**的每一段各自走 finishNarration(抹平 `` / 折叠空行 / 剥行内标签),
+// 因此「空气泡丢弃」与 `
` 空气泡同一套口径:只由空白构成的段会被 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 只按 `
` 类分隔符切分(分隔符本身丢弃),不做任何清洗。
+// 没有分隔符时返回单元素切片,即「整段一个气泡」的既有语义。
+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 前端
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。