From fa573663c149cc44973803b5b70860bee98ad250 Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Sat, 24 Oct 2026 19:21:50 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix(turnreply):=20=E5=AD=97=E9=9D=A2=20\n?=
=?UTF-8?q?=20=E6=B8=B2=E6=9F=93=E6=88=90=E7=9C=9F=E6=8D=A2=E8=A1=8C?=
=?UTF-8?q?=EF=BC=88Web=20=E9=95=9C=E5=83=8F=E4=B8=8E=20IM=20=E5=87=BA?=
=?UTF-8?q?=E7=AB=99=E5=90=8C=E4=B8=80=E4=BB=BD=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
主人 2026-10-24 报障:QQ 群最新一条 assistant 消息里 6 处字面 `\n`
(反斜杠 + n,不是真换行)原样发出去,用户看到「F\n多利亚」。
落点选在**写入侧共享层** `turnreply.NormalizeParagraphs`(gateway 的
persistTurnReply 与 channelbridge 的 turnVisibleReply 都调它):
IM 的 deliverReply 拿同一个字符串既落库(= Web 主会话镜像)又发气泡,
Web chat 气泡是 whitespace-pre-wrap ⇒ 在共享层渲染,落库/WS/REST/IM
出站与历史一次性对齐;只在 channelbridge 清洗层改会留下「面板上仍是字面 \n」。
- 新增 internal/runtime/turnreply/literal_breaks.go 的 RenderLiteralLineBreaks:
逐字节扫描,围栏(```)与行内代码(`…` / ``…``)内原样保留,`\n` 与
`\t`/`\r`/`\uXXXX`、行尾孤立 `\` 一律不动;未闭合围栏保守保护到末尾;幂等。
- paragraph.go 在**围栏 skip 之前**调用它 ⇒ 含围栏的消息里围栏外的 `\n` 也渲染。
- 🔴 未改既有语义:只产出换行字符,绝不产出
/
——
仍是气泡分隔符,
字面 \n 只做气泡内换行(一条列表不会被拆成 7 条消息)。
- channelbridge 源码零改动。
验证:真机原文(6 处 \n / 5 个
)修前红(摘掉钩子 3 个测试变红)→ 修后绿; 三处变异(去掉围栏保护 / 去掉 \ 配对 / 把渲染挪到围栏 skip 之后)各自变红后还原复绿。 全树 go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包)。 --- internal/runtime/turnreply/literal_breaks.go | 140 ++++++++++++ .../runtime/turnreply/literal_breaks_test.go | 211 ++++++++++++++++++ internal/runtime/turnreply/paragraph.go | 8 +- .../sanitize_literal_breaks_test.go | 146 ++++++++++++ 4 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 internal/runtime/turnreply/literal_breaks.go create mode 100644 internal/runtime/turnreply/literal_breaks_test.go create mode 100644 internal/services/channelbridge/sanitize_literal_breaks_test.go diff --git a/internal/runtime/turnreply/literal_breaks.go b/internal/runtime/turnreply/literal_breaks.go new file mode 100644 index 0000000..90b8f8b --- /dev/null +++ b/internal/runtime/turnreply/literal_breaks.go @@ -0,0 +1,140 @@ +package turnreply + +import ( + "math" + "strings" +) + +// 本文件承载助手文本里**字面转义**的渲染:模型偶尔把换行写成**两个字符** +// `\n`(反斜杠 + n),而不是一个真换行。 +// +// 缺陷形状(主人 2026-10-24 报障,真机原文):她列了七个中古调式,用字面 `\n` +// 当分隔,全挤在**同一个 `
`** 里 —— +// +//
伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:…\n弗里吉亚:…
+// +// 清洗链不认这两个字符(全仓没有任何一处把字面 `\n` 当换行),于是原样发给 QQ, +// 用户看到的是「F\n多利亚」,Web 气泡里也一样(`whitespace-pre-wrap` 只对**真** +// 换行生效)。⇒ 这一层是**写入侧规整**:把字面 `\n` 换成真换行,落库文本、WS +// `message_persisted`、REST reply、IM 出站气泡就是**同一份**(与 +// NormalizeParagraphs 同一条口径,见 paragraph.go 的调用点)。 +// +// 判据与边界: +// - 只认**恰好一个**反斜杠紧跟 `n`。`\\n`(双反斜杠 + n,模型在讲转义规则或 +// 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠 +// 加字母 n」,把它当换行等于篡改内容。 +// - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论 +// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。 +// - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层 +// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。 +// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `` ——
+// `
` 在 chat 口径里是**气泡/消息分隔符**(把一条列表升级成七条消息反而更吵),
+// 字面 `\n` 只应是**气泡内部的换行**。
+// - 幂等:渲染后的文本里不再有可被本函数改写的字面 `\n`(代码里的那些本来就不动),
+// 因此二次调用逐字相同。
+
+const (
+ // literalBreakEscape 是模型实际写出的「字面换行」:反斜杠 + n 两个字符。
+ literalBreakEscape = `\n`
+ // backtickCh 是代码围栏与行内代码的定界符。
+ backtickCh = '`'
+ // backslashCh 是转义引导符。
+ backslashCh = '\\'
+ // fenceRunLen 是围栏代码块的定界符长度(paragraph.go 的 codeFenceMarker 是
+ // 三个反引号;它在 var 块里,len() 不是常量,故这里写死同一个 3)。
+ fenceRunLen = 3
+ // unboundedRun 表示「反引号串长度不设上限」(围栏的闭合串可以更长,
+ // 如 ````> 也能闭合 ``` 开的围栏)。
+ unboundedRun = math.MaxInt
+)
+
+// RenderLiteralLineBreaks 返回把字面 `\n` 渲染成真换行后的文本:代码围栏与行内
+// 代码内原样保留,`\\n` 与其余转义不动,不含字面 `\n` 的文本逐字返回(零分配路径)。
+func RenderLiteralLineBreaks(text string) string {
+ if !strings.Contains(text, literalBreakEscape) {
+ return text
+ }
+ var b strings.Builder
+ b.Grow(len(text) + 8)
+ for i := 0; i < len(text); {
+ switch text[i] {
+ case backtickCh:
+ // 围栏/行内代码:整段逐字复制,内部的 `\n` 一个字不动。
+ end := codeProtectedEnd(text, i)
+ b.WriteString(text[i:end])
+ i = end
+ case backslashCh:
+ switch {
+ case i+1 >= len(text):
+ // 行尾孤立的 `\`:原文保留。
+ b.WriteByte(text[i])
+ i++
+ case text[i+1] == 'n':
+ b.WriteByte('\n')
+ i += 2
+ case text[i+1] == backslashCh:
+ // `\\` 成对吞掉 ⇒ 紧随其后的 `n` 是普通字母,`\\n` 因此原样保留。
+ b.WriteString(text[i : i+2])
+ i += 2
+ default:
+ // `\t` / `\r` / `\u0041` 等:本层不处理,原样保留。
+ b.WriteByte(text[i])
+ i++
+ }
+ default:
+ b.WriteByte(text[i])
+ i++
+ }
+ }
+ return b.String()
+}
+
+// codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开):
+// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于下一个 ``` 起的反引号串
+// (含该串本身)。没有闭合围栏时**保护到文本末尾** —— 宁可少渲染一处换行,
+// 也不把可能仍是代码的正文改坏。
+// - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串
+// (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符,
+// 区间就是它们自身(不吞后面的正文)。
+func codeProtectedEnd(text string, start int) int {
+ n := backtickRunLen(text, start)
+ body := start + n
+ if n >= fenceRunLen {
+ if close := findBacktickRun(text, body, fenceRunLen, unboundedRun); close >= 0 {
+ return close + backtickRunLen(text, close)
+ }
+ return len(text)
+ }
+ if close := findBacktickRun(text, body, n, n); close >= 0 {
+ return close + n
+ }
+ return body
+}
+
+// findBacktickRun 在 text[from:] 里找**下一个**长度落在
+// [atLeast, atMost] 区间内的连续反引号串的起始下标,找不到返回 -1。
+// 围栏用 atLeast=fenceRunLen / atMost=unboundedRun(更长的围栏也能闭合),
+// 行内代码用 atLeast==atMost==定界符长度(等长才算闭合)。
+func findBacktickRun(text string, from, atLeast, atMost int) int {
+ for i := from; i < len(text); {
+ if text[i] != backtickCh {
+ i++
+ continue
+ }
+ r := backtickRunLen(text, i)
+ if r >= atLeast && r <= atMost {
+ return i
+ }
+ i += r
+ }
+ return -1
+}
+
+// backtickRunLen 返回 text[at:] 起连续反引号的个数(at 越界时为 0)。
+func backtickRunLen(text string, at int) int {
+ n := 0
+ for at+n < len(text) && text[at+n] == backtickCh {
+ n++
+ }
+ return n
+}
diff --git a/internal/runtime/turnreply/literal_breaks_test.go b/internal/runtime/turnreply/literal_breaks_test.go
new file mode 100644
index 0000000..705066f
--- /dev/null
+++ b/internal/runtime/turnreply/literal_breaks_test.go
@@ -0,0 +1,211 @@
+package turnreply
+
+import (
+ "strings"
+ "testing"
+)
+
+// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」(literal_breaks.go)的判据。
+//
+// 重点三条:
+// - **代码内容不许被改**:围栏(```)与行内代码(`…`)里的 `\n` 逐字不动 ——
+// 模型讨论代码时写 `print("a\nb")`,全局替换会把代码改坏;
+// - **`\\n` 不动**:它表示「一个反斜杠 + 字母 n」,当换行等于篡改内容;
+// - **幂等**:它跑在每一条助手回复的写入路径上,二次调用必须逐字相同。
+
+// literalEscapeSample 是模型实际写出的那两个字符:反斜杠 + n(不是换行)。
+const literalEscapeSample = "\\n"
+
+// realLiteralBreakReply 是真机原文(主人 2026-10-24 报障那条乐理回复,6 处字面
+// `\n` 全挤在同一个
里)。它与 channelbridge 侧 +// sanitize_literal_breaks_test.go 的同名字面量**逐字相同** —— 那正是「网关/落库 +// 与 IM 出站吃同一份文本」的断言方式(同一份样本,两侧各自断言)。 +const realLiteralBreakReply = `
伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭
+ +那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。
+ +不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪
` + +// TestRenderLiteralLineBreaks_RealMachineMessage 是必测①的**修前红/修后绿**判据: +// 真机原文的 6 处字面 `\n` 全部渲染成真换行,段落结构(5 个)一字不变,且
+// 绝不引入 `
`(它不是气泡分隔符,只是气泡内的换行)。
+func TestRenderLiteralLineBreaks_RealMachineMessage(t *testing.T) {
+ t.Parallel()
+
+ got := RenderLiteralLineBreaks(realLiteralBreakReply)
+ if strings.Contains(got, literalEscapeSample) {
+ t.Fatalf("仍剩字面 `\\n` —— QQ 上还会看到「F\\n多利亚」:\n%q", got)
+ }
+ if wantGain := strings.Count(realLiteralBreakReply, literalEscapeSample); strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n") != wantGain {
+ t.Fatalf("新增真换行 = %d, want %d", strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n"), wantGain)
+ }
+ if n := strings.Count(got, "
"); n != 5 { + t.Fatalf("
块 = %d, want 5(段落口径不得被改动)", n)
+ }
+ if strings.Contains(got, "
—— 字面 `\\n` 只应是气泡内换行,不是消息分隔符:\n%q", got)
+ }
+ // 逐字校验第三段:七个调式各占一行。
+ const scaleHead = "
伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:" + if !strings.Contains(got, scaleHead) { + t.Fatalf("调式段没有被换行渲染:\n%q", got) + } +} + +// TestRenderLiteralLineBreaks_CodeFenceProtected 是必测②:围栏代码块里的 `\n` +// 逐字保留(她解释 `print("a\nb")` 时那两下是**代码内容**),围栏前后的正文照常渲染。 +func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) { + t.Parallel() + + in := "先说明一句" + literalEscapeSample + "这里要换行。\n" + + "```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```\n" + + "围栏之后这句" + literalEscapeSample + "也换行。" + got := RenderLiteralLineBreaks(in) + + if !strings.Contains(got, `fmt.Print("a\nb")`) { + t.Fatalf("围栏内的 `\\n` 被改坏了(代码内容必须逐字保留):\n%q", got) + } + if got := strings.Count(RenderLiteralLineBreaks(in), literalEscapeSample); got != 1 { + t.Fatalf("剩余字面 `\\n` = %d, want 1(只该剩围栏里那一处)", got) + } + for _, want := range []string{"先说明一句\n这里要换行。", "围栏之后这句\n也换行。"} { + if !strings.Contains(got, want) { + t.Fatalf("围栏外正文没有被渲染,want 含 %q:\n%q", want, got) + } + } +} + +// TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码 +// (“ `…` “ 与等长的 “ “…“ “)里的 `\n` 也不动。 +func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) { + t.Parallel() + + in := "行内代码 `a" + literalEscapeSample + "b` 里那两下不动;真的换行这样写:" + literalEscapeSample + "下一句。" + got := RenderLiteralLineBreaks(in) + + if !strings.Contains(got, "行内代码 `a"+literalEscapeSample+"b` 里那两下不动") { + t.Fatalf("行内代码里的 `\\n` 被改坏了:\n%q", got) + } + if !strings.HasSuffix(got, "真的换行这样写:\n下一句。") { + t.Fatalf("行内代码之外的 `\\n` 没被渲染:\n%q", got) + } + + // 双反引号定界符同样受保护。 + double := "``a" + literalEscapeSample + "b`` 不动,外面" + literalEscapeSample + "要换行" + if got := RenderLiteralLineBreaks(double); !strings.Contains(got, "``a"+literalEscapeSample+"b``") || + !strings.HasSuffix(got, "外面\n要换行") { + t.Fatalf("双反引号行内代码处理错:\n%q", got) + } + + // 落单反引号(没有等长闭合串)只是普通字符,不吞掉后面的正文。 + stray := "一个孤零零的反引号 `,然后" + literalEscapeSample + "这里照常换行" + if got := RenderLiteralLineBreaks(stray); !strings.HasSuffix(got, "然后\n这里照常换行") { + t.Fatalf("落单反引号把后面的正文一并保护了(应为普通字符):\n%q", got) + } +} + +// TestRenderLiteralLineBreaks_DoubleBackslashUntouched 是必测③:`\\n`(双反斜杠 + +// n,模型讲转义规则或写路径时会写)**原样保留** —— 它的语义是一个反斜杠加字母 n。 +// 顺带钉住「`\\` 之后紧跟的字面 `\n` 仍要渲染」这条配对口径。 +func TestRenderLiteralLineBreaks_DoubleBackslashUntouched(t *testing.T) { + t.Parallel() + + const doubleBackslash = "\\\\n" // \\n:两个反斜杠 + n + unchanged := "字面反斜杠加 n 要写成 " + doubleBackslash + " 这样" + if got := RenderLiteralLineBreaks(unchanged); got != unchanged { + t.Fatalf("`\\\\n` 被改动了:\n got %q\nwant %q", got, unchanged) + } + + paired := "先一个转义的反斜杠 \\\\ 然后" + literalEscapeSample + "这里换行" + if got := RenderLiteralLineBreaks(paired); !strings.HasSuffix(got, "然后\n这里换行") { + t.Fatalf("`\\\\` 之后的字面 `\\n` 没有被渲染:\n%q", got) + } +} + +// TestRenderLiteralLineBreaks_OtherEscapesUntouched 记录本层的**范围边界**:只认 +// `\n` 这一个形状,`\t` / `\r` / 行尾孤立的 `\` 一律不动(其余字面转义见切片报告, +// 本片不扩大语义)。 +func TestRenderLiteralLineBreaks_OtherEscapesUntouched(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ name, in string }{ + {"tab", "缩进" + "\\t" + "不处理"}, + {"carriage", "回车" + "\\r" + "不处理"}, + {"trailing-backslash", "行尾孤立的反斜杠\\"}, + {"unicode", "转义" + "\\u00e9" + "不处理"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := RenderLiteralLineBreaks(tc.in); got != tc.in { + t.Fatalf("%s:被本层改动了:\n got %q\nwant %q", tc.name, got, tc.in) + } + }) + } +} + +// TestRenderLiteralLineBreaks_UnclosedFenceProtected 是保守兜底:围栏没闭合时, +// 后面的文本**一律原样保留**(宁可少渲染一处换行,也不把可能仍是代码的正文改坏)。 +func TestRenderLiteralLineBreaks_UnclosedFenceProtected(t *testing.T) { + t.Parallel() + + in := "```go\n代码里的" + literalEscapeSample + "不动" + if got := RenderLiteralLineBreaks(in); got != in { + t.Fatalf("未闭合围栏之后的正文被改了:\n got %q\nwant %q", got, in) + } +} + +// TestRenderLiteralLineBreaks_IdempotentAndFastPath 是守门断言:没有字面 `\n` 的 +// 文本逐字返回;有的话二次调用逐字相同(它跑在每条回复的写入路径上,破掉幂等就会 +// 每读改写一次叠一层)。 +func TestRenderLiteralLineBreaks_IdempotentAndFastPath(t *testing.T) { + t.Parallel() + + for _, plain := range []string{"", "普通一句话,没有字面转义。", "
已经规整好的段落。
"} { + if got := RenderLiteralLineBreaks(plain); got != plain { + t.Fatalf("无字面 `\\n` 的文本被改动:\n got %q\nwant %q", got, plain) + } + } + samples := []string{ + realLiteralBreakReply, + "甲" + literalEscapeSample + "乙", + "围栏外" + literalEscapeSample + "换行\n```\n代码" + literalEscapeSample + "\n```", + } + for _, s := range samples { + once := RenderLiteralLineBreaks(s) + if twice := RenderLiteralLineBreaks(once); twice != once { + t.Fatalf("不幂等:\n once %q\ntwice %q", once, twice) + } + } +} + +// TestNormalizeParagraphs_CarriesLiteralBreaks 是必测①的**落点**断言:字面 `\n` +// 的渲染挂在 NormalizeParagraphs 上 —— 网关(Web)与渠道桥(IM)都调这一个函数, +// 因此落库、WS/REST 与 IM 出站拿到的是同一份真换行文本。 +// +// 含围栏时段落规整整体 skip,但**围栏外的**字面 `\n` 仍必须被渲染(渲染刻意放在 +// 围栏 skip 之前),围栏内的不动。 +func TestNormalizeParagraphs_CarriesLiteralBreaks(t *testing.T) { + t.Parallel() + + got := NormalizeParagraphs(realLiteralBreakReply) + if strings.Contains(got, literalEscapeSample) { + t.Fatalf("NormalizeParagraphs 没有渲染字面 `\\n`:\n%q", got) + } + if n := strings.Count(got, ""); n != 5 { + t.Fatalf("
块 = %d, want 5", n) + } + + mixed := "
甲" + literalEscapeSample + "乙
\n\n" + + "```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```" + got = NormalizeParagraphs(mixed) + if !strings.Contains(got, "甲\n乙
") { + t.Fatalf("含围栏的消息里,围栏外那段没有被渲染:\n%q", got) + } + if !strings.Contains(got, `fmt.Print("a\nb")`) { + t.Fatalf("围栏内的 `\\n` 被改坏了:\n%q", got) + } +} diff --git a/internal/runtime/turnreply/paragraph.go b/internal/runtime/turnreply/paragraph.go index ce3a06e..2b18aa7 100644 --- a/internal/runtime/turnreply/paragraph.go +++ b/internal/runtime/turnreply/paragraph.go @@ -56,8 +56,14 @@ var ( // paragraph_repair.go)。 // // 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) == -// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。 +// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本按段落口径原样返回。 +// +// 开头先跑 `RenderLiteralLineBreaks`(literal_breaks.go):模型把换行写成字面 `\n` +// 时,落库/WS/REST/IM 出站要拿到**同一份**真换行文本。这一步刻意放在**围栏 skip +// 之前** —— 否则含代码围栏的消息里,围栏外那些字面 `\n` 就一律不渲染了;围栏与 +// 行内代码内部由那个函数自己保护,段落口径的 skip 不受影响。 func NormalizeParagraphs(text string) string { + text = RenderLiteralLineBreaks(text) if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) { return text } diff --git a/internal/services/channelbridge/sanitize_literal_breaks_test.go b/internal/services/channelbridge/sanitize_literal_breaks_test.go new file mode 100644 index 0000000..d99549e --- /dev/null +++ b/internal/services/channelbridge/sanitize_literal_breaks_test.go @@ -0,0 +1,146 @@ +package channelbridge + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/user" +) + +// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」在 **IM 侧真流水线**上的落点, +// 两件必须同时成立的事: +// 1. **落库(= Web 主会话镜像)与出站气泡吃同一份文本** —— 主人私聊的回复会镜像进 +// Web 主会话,若只修出站,面板上仍会看到字面 `\n`(那正是这条修复的判据); +// 2. 🔴 **`里)。 + +// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。 +const literalEscape = `\n` + +// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字节、5 个
、 +// 6 处字面 `\n`、不含反引号)。断言依赖这三条性质,改样本时必须同步。 +const realLiteralBreakReply = `
伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭
+ +那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。
+ +不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪
` + +// TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条 +// 回复的第三个是一行字面 `F\n多利亚`;修复后同一个气泡里是**七行**,而落库 +// 镜像与出站气泡是同一份文本。 +func TestTurnReply_LiteralBreakRenderedForBothSides(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: realLiteralBreakReply}} + b, ad, hist := replyTurnBridge(t, chat) + b.bubbleGapFn = func() time.Duration { return 0 } + + b.process(context.Background(), directMessage()) + + // ① 落库(= Web 主会话镜像):6 处字面 `\n` 全部变成真换行,段落结构一字不变。 + want := strings.ReplaceAll(realLiteralBreakReply, literalEscape, "\n") + rec := assertAssistantReply(t, hist, want) + if got := strings.Count(rec.Content, literalEscape); got != 0 { + t.Fatalf("落库文本仍有 %d 处字面 `\\n` —— Web 面板上仍会显示 F\\n多利亚", got) + } + // 段落结构一字不变:仍是 5 个
(字面 `\n` 绝不升级成段落或
)。
+ if got := strings.Count(rec.Content, "
"); got != 5 { + t.Fatalf("落库文本
块 = %d, want 5(段落口径不得被改动)", got) + } + wantLines := strings.Count(realLiteralBreakReply, "\n") + 6 + if got := strings.Count(rec.Content, "\n"); got != wantLines { + t.Fatalf("落库文本真换行 = %d, want %d(6 处字面 `\\n` 应各变成一个换行)", got, wantLines) + } + + // ② 出站:5 条气泡(
一级、
二级的既有语义不变),第三条是七行。
+ if len(ad.sends) != 5 {
+ t.Fatalf("出站气泡 = %d 条, want 5(字面 `\\n` 绝不能切成消息): %+v", len(ad.sends), ad.sends)
+ }
+ scale := ad.sends[2].Content
+ lines := strings.Split(scale, "\n")
+ if len(lines) != 7 {
+ t.Fatalf("调式气泡 = %d 行, want 7(修前是 1 行字面 F\\n多利亚): %q", len(lines), scale)
+ }
+ if !strings.HasPrefix(lines[0], "伊奥尼亚:") || !strings.HasPrefix(lines[6], "洛克里亚:") {
+ t.Fatalf("调式气泡首尾行 = %q / %q, want 伊奥尼亚… / 洛克里亚…", lines[0], lines[6])
+ }
+ for i, sent := range ad.sends {
+ if strings.Contains(sent.Content, literalEscape) {
+ t.Fatalf("气泡[%d] 仍有字面 `\\n`: %q", i, sent.Content)
+ }
+ if strings.Contains(sent.Content, "
—— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content)
+ }
+ }
+
+ // ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。
+ if !strings.Contains(rec.Content, scale) {
+ t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content)
+ }
+}
+
+// TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`
` 仍
+// 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的
+// 判据一个字节都没动。
+func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) {
+ t.Parallel()
+
+ chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + "乙
丙" + literalEscape + "丁"}}
+ b, ad, hist := replyTurnBridge(t, chat)
+ b.bubbleGapFn = func() time.Duration { return 0 }
+
+ b.process(context.Background(), directMessage())
+
+ assertAssistantReply(t, hist, "
甲\n乙
丙\n丁
第一行\n第二行
"; got != want { + t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want) + } +} From 6b0af81136c0a0dac7146334944153a91a365d7d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 19:33:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(turnreply):=20=E5=9B=B4=E6=A0=8F?= =?UTF-8?q?=E9=97=AD=E5=90=88=E4=B8=B2=E4=B8=8D=E5=BE=97=E7=9F=AD=E4=BA=8E?= =?UTF-8?q?=E5=BC=80=E5=90=AF=E4=B8=B2=EF=BC=88=E5=A4=8D=E5=AE=A1=20p2=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号 的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**, 因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go, 示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通 正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进 历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。 - literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun) (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。 - 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、 围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。 - 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒ 两段 ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
主动推送不经 NormalizeParagraphs,另计」。
行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
---
internal/runtime/turnreply/literal_breaks.go | 13 +++---
.../runtime/turnreply/literal_breaks_test.go | 24 ++++++++++-
.../sanitize_literal_breaks_test.go | 42 ++++++++++++++++---
3 files changed, 68 insertions(+), 11 deletions(-)
diff --git a/internal/runtime/turnreply/literal_breaks.go b/internal/runtime/turnreply/literal_breaks.go
index 90b8f8b..0e9fd8b 100644
--- a/internal/runtime/turnreply/literal_breaks.go
+++ b/internal/runtime/turnreply/literal_breaks.go
@@ -24,7 +24,9 @@ import (
// 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠
// 加字母 n」,把它当换行等于篡改内容。
// - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论
-// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。
+// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。围栏的闭合串
+// 按 CommonMark 取「不短于开启串」(```` 开的围栏不能被内层 ``` 关闭),行内
+// 代码取「等长」,因此嵌套围栏示例(```go 套在 ````markdown 里)同样受保护。
// - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层
// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。
// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `
` / `
` —— @@ -90,9 +92,10 @@ func RenderLiteralLineBreaks(text string) string { } // codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开): -// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于下一个 ``` 起的反引号串 -// (含该串本身)。没有闭合围栏时**保护到文本末尾** —— 宁可少渲染一处换行, -// 也不把可能仍是代码的正文改坏。 +// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于**不短于开启串**的反引号串 +// (CommonMark 口径:```` 开的围栏不能被内层的 ``` 关闭 —— 否则嵌套围栏示例里的 +// `\n` 会被当成正文渲染,而这段文本紧接着就落库)。没有闭合围栏时**保护到文本 +// 末尾** —— 宁可少渲染一处换行,也不把可能仍是代码的正文改坏。 // - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串 // (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符, // 区间就是它们自身(不吞后面的正文)。 @@ -100,7 +103,7 @@ func codeProtectedEnd(text string, start int) int { n := backtickRunLen(text, start) body := start + n if n >= fenceRunLen { - if close := findBacktickRun(text, body, fenceRunLen, unboundedRun); close >= 0 { + if close := findBacktickRun(text, body, n, unboundedRun); close >= 0 { return close + backtickRunLen(text, close) } return len(text) diff --git a/internal/runtime/turnreply/literal_breaks_test.go b/internal/runtime/turnreply/literal_breaks_test.go index 705066f..97da72c 100644 --- a/internal/runtime/turnreply/literal_breaks_test.go +++ b/internal/runtime/turnreply/literal_breaks_test.go @@ -79,8 +79,30 @@ func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) { } } +// TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun 钉住 CommonMark 的围栏闭合 +// 口径:**闭合串不得短于开启串**。四个反引号开启的围栏里嵌着三反引号的示例时,内层 +// ``` 只是**代码内容**,不能把外层围栏闭合 —— 否则示例里的 `print("a\nb")` 会被渲染成 +// 真换行,而这段文本紧接着就落库(改坏的代码会永久进历史)。 +func TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun(t *testing.T) { + t.Parallel() + + const nested = "````markdown\n```go\nfmt.Println(\"a" + literalEscapeSample + "b\")\n```\n````" + in := "教你怎么写围栏代码块:\n\n" + nested + "\n\n围栏之后这句" + literalEscapeSample + "也换行。" + got := RenderLiteralLineBreaks(in) + + if !strings.Contains(got, nested) { + t.Fatalf("嵌套围栏没有被逐字保留(内层 ``` 提前闭合了外层 ````):\n got %q\nwant 含 %q", got, nested) + } + if strings.Contains(got, "fmt.Println(\"a\nb\")") { + t.Fatalf("嵌套围栏里的字面 `\\n` 被渲染成真换行 —— 代码内容被改坏:\n%q", got) + } + if !strings.HasSuffix(got, "围栏之后这句\n也换行。") { + t.Fatalf("围栏之外的正文没被渲染(保护不得溢出):\n%q", got) + } +} + // TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码 -// (“ `…` “ 与等长的 “ “…“ “)里的 `\n` 也不动。 +// (单个反引号与两个反引号两种定界符)里的 `\n` 也不动。 func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) { t.Parallel() diff --git a/internal/services/channelbridge/sanitize_literal_breaks_test.go b/internal/services/channelbridge/sanitize_literal_breaks_test.go index d99549e..582c4be 100644 --- a/internal/services/channelbridge/sanitize_literal_breaks_test.go +++ b/internal/services/channelbridge/sanitize_literal_breaks_test.go @@ -23,8 +23,8 @@ import ( // literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。 const literalEscape = `\n` -// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字节、5 个
、 -// 6 处字面 `\n`、不含反引号)。断言依赖这三条性质,改样本时必须同步。 +// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、 +// 5 个
、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。 const realLiteralBreakReply = `
` ⇒ 两端各两条气泡。 +// +// 语义上说得通(空行 = 段落),但它与「字面 `\n` 只做气泡内换行」不是同一句话 —— +// 这里显式钉住,让日后任何改动**有意**改这个气泡数,而不是悄悄变。 +func TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + literalEscape + "乙"}} + b, ad, hist := replyTurnBridge(t, chat) + b.bubbleGapFn = func() time.Duration { return 0 } + + b.process(context.Background(), directMessage()) + + // 落库(Web 镜像):两段
,中间保留空行。 + assertAssistantReply(t, hist, "
甲
\n\n乙
") + if len(ad.sends) != 2 { + t.Fatalf("出站气泡 = %d 条, want 2(`\\n\\n` ⇒ 空行 ⇒ 段落分隔): %+v", len(ad.sends), ad.sends) + } + if got, want := ad.sends[0].Content, "甲"; got != want { + t.Fatalf("气泡[0] = %q, want %q", got, want) + } + if got, want := ad.sends[1].Content, "乙"; got != want { + t.Fatalf("气泡[1] = %q, want %q", got, want) + } +}