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 }