fix(turnreply): 字面 \n 渲染成真换行(Web 镜像与 IM 出站同一份)

主人 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` 也渲染。
- 🔴 未改既有语义:只产出换行字符,绝不产出 <br>/<p> —— <br> 仍是气泡分隔符,
  字面 \n 只做气泡内换行(一条列表不会被拆成 7 条消息)。
- channelbridge 源码零改动。

验证:真机原文(6 处 \n / 5 个 <p>)修前红(摘掉钩子 3 个测试变红)→ 修后绿;
三处变异(去掉围栏保护 / 去掉 \ 配对 / 把渲染挪到围栏 skip 之后)各自变红后还原复绿。
全树 go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包)。
This commit is contained in:
AskaEth committed 2026-10-24 19:21:50 +08:00
1 parent 4ed5925296
commit fa573663c1
4 files changed
+504 -1

No files matched your search

@@ -0,0 +1,140 @@
package turnreply
import (
"math"
"strings"
)
// 本文件承载助手文本里**字面转义**的渲染:模型偶尔把换行写成**两个字符**
// `\n`(反斜杠 + n),而不是一个真换行。
//
// 缺陷形状(主人 2026-10-24 报障,真机原文):她列了七个中古调式,用字面 `\n`
// 当分隔,全挤在**同一个 `<p>`** 里 ——
//
// <p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:…\n弗里吉亚:…</p>
//
// 清洗链不认这两个字符(全仓没有任何一处把字面 `\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` / 行尾单独的 `\`)**一律不动**:这一层
// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。
// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `<br>` / `<p>` ——
// `<br>` 在 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
}