主人 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 包)。
141 lines
5.6 KiB
Go
141 lines
5.6 KiB
Go
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
|
||
}
|