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")` 是**代码内容**,全局替换会把代码改坏。围栏的闭合串 // 按 CommonMark 取「不短于开启串」(```` 开的围栏不能被内层 ``` 关闭),行内 // 代码取「等长」,因此嵌套围栏示例(```go 套在 ````markdown 里)同样受保护。 // - 不认识的转义(`\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` 处是 ``` 起(含更长):一个围栏代码块,止于**不短于开启串**的反引号串
// (CommonMark 口径:```` 开的围栏不能被内层的 ``` 关闭 —— 否则嵌套围栏示例里的
// `\n` 会被当成正文渲染,而这段文本紧接着就落库)。没有闭合围栏时**保护到文本
// 末尾** —— 宁可少渲染一处换行,也不把可能仍是代码的正文改坏。
// - `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, n, 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
}