From a19d12b228fdad1305d4ca55efa01024382f8b36 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 17:32:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(markup):=20br=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=B0=94=E6=B3=A1/=E6=B6=88=E6=81=AF=E5=88=86=E9=9A=94?= =?UTF-8?q?=E7=AC=A6=20+=20=E8=A1=8C=E5=86=85=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=88=B0=E4=BA=94=E9=A1=B9=EF=BC=88Web=20+?= =?UTF-8?q?=20IM=20=E4=B8=A4=E4=BE=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-07:「对消息内的
始终处理为多条消息隔断」。 Web 侧(frontend/web): - chat.tsx 的 splitParagraphs(SplitChatBubble 的切分层)新增 BR_SEPARATOR_RE 与 splitBubbleBreaks:
/
/
等价、属性不解析、/ 挡掉、 流式半截
的段逐字返回原样(既有 p 口径一字节不动) - inlineMarkup.ts 删掉 break 节点(br 不再是行内元素),白名单收窄为 b/strong/i/em/code 五项;renderContent.tsx 同步;测试 76 -> 99 checks - 40 条基线逐字节对照:28 IDENTICAL,12 个 DIFF 恰是含 br/u/s/
的样例 IM 侧(internal/services/channelbridge): - 新增 inline_tags.go:成对平衡的白名单行内标签剥标签留文本、白名单外与 结构不平衡整段原样保留;br 作为第二级分隔符交给 splitBubbles - 两级切分:p 块为第一级,块内按 br 再切为第二级;空气泡丢弃 - br 切分放在角色注记摘除之后:否则 action 里的 br 会把注记正文漏成聊天气泡 (变异专门钉住这条顺序) 两路各 4 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。 --- frontend/web/src/components/chat.tsx | 66 +++- frontend/web/src/lib/inlineMarkup.ts | 31 +- .../web/src/lib/renderContent.render.test.ts | 82 ++++- frontend/web/src/lib/renderContent.tsx | 14 +- .../services/channelbridge/inline_tags.go | 125 +++++++ .../channelbridge/inline_tags_test.go | 306 ++++++++++++++++++ internal/services/channelbridge/sanitize.go | 82 ++++- 7 files changed, 646 insertions(+), 60 deletions(-) create mode 100644 internal/services/channelbridge/inline_tags.go create mode 100644 internal/services/channelbridge/inline_tags_test.go 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 ; } -/** Split a chat reply's annotation-stripped body into `

`-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. */ +/** `
` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `
` 始终处理为多条 + * 消息隔断」)。`
` / `
` / `
` 三种写法等价(HTML5 口径),属性不解析 + * (`
` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏 + * —— `` / `` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」 + * 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`` 不算标签)。 */ +const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i; + +/** 把一个已切好的段落再按 `
` 切开:`
` 与 `

` **同级** —— 两边的文本各自 + * 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。空气泡丢弃 + * (`

甲

乙

` ⇒ `甲` / `乙`;`
` 两侧都是空白 ⇒ 不产生多余气泡)。 + * **不含 `
` 的段落逐字返回原样**(空段也原样保留)⇒ 既有 `

` 口径一个字节不动。 */ +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: `

…

` (contract §4.16, one `

` = one + * bubble) and `
` (主人 2026-10-07: `
` is a message separator, never an + * in-bubble line break). A body with no `

` 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

…

(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 / 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
inside a paragraph cuts it into several bubbles. const pRe = /]*>([\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 `` / `` block. @@ -348,12 +370,14 @@ function UserParaBubble({ /** Chat-only entry: a `chat`-mode reply's ``/`` 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 `

` splitting, and its content (which may itself - * contain `

` / `` / `` as literal text or code) is never - * re-parsed. */ + * plain-text body is split into stacked bubbles by `splitParagraphs` — two peers + * delimit a bubble: `

…

` (§4.16) and `
` (主人 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 `

` / `` / `` 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 ; + // usual chrome (avatar, mood, "生成中…"). 一处例外:正文只剩 `
`(气泡分隔被切 + // 光)时**不能**把原文交下去 —— 那会把字面量 `
` 画进气泡,等于凭空造一个空气泡。 + // 这种情况按「空正文」交下去(外壳照旧),与空气泡丢弃的口径一致。 + const collapsedByBreaks = BR_SEPARATOR_RE.test(body); + return ( + + ); } // Group the paragraphs of one reply into a tighter column so intra-reply diff --git a/frontend/web/src/lib/inlineMarkup.ts b/frontend/web/src/lib/inlineMarkup.ts index ea5b4fa..57df78e 100644 --- a/frontend/web/src/lib/inlineMarkup.ts +++ b/frontend/web/src/lib/inlineMarkup.ts @@ -5,34 +5,41 @@ // 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `` 之类强调标签。 // 后两类此前没有处理 ⇒ `18:35` 会把字面量 `<b>` 直接显示给用户。 // +// 🚫 **`
` 不在这里**(主人 2026-10-07 口径「对消息内的 `
` 始终处理为多条消息 +// 隔断」):它是**气泡/消息分隔符**,与 `

` 同级,由 `components/chat.tsx` 的 +// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 `
` 走到本模块 +// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示, +// 绝不在这里伪造一个「气泡内换行」。 +// // 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导), // 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。 // 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React // 对文本节点默认转义,天然安全。规则: -// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `

` 机制表达); +// ① 只认**行内**白名单标签(五项:`b` / `strong` / `i` / `em` / `code`),块级一律 +// 不支持(段落仍由 §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不是段落标签", + "弯起眼睛笑了
\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"}, + {"

你好呀

\n心里有点甜\n

今天天气不错

", "你好呀\n今天天气不错"}, + {"就是普通一句话,没有动作。\n第二行。", "就是普通一句话,没有动作。\n第二行。"}, + {"拍拍你", ""}, + {"*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"}, + {"*旁白:心里有点甜*\n这是正文。", "这是正文。"}, + {"这是一段 *强调* 文字,不是动作。", "这是一段 *强调* 文字,不是动作。"}, + {"*弯起眼睛*", ""}, + {"

a

\n\n\n

b

", "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 前端 // 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。