Files
AskaEth fa573663c1 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 包)。
2026-10-24 19:21:50 +08:00

162 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package turnreply
import (
"regexp"
"sort"
"strings"
)
// 本文件承载助手文本的**段落包裹规整**:把「只包了最后一段」的半包回复,在写入
// 侧规整成「每个段落一个 <p>…</p>」的规范形态。
//
// 缺陷形状(安卓侧实测):模型的多数回复按段产出 `<p>…</p>`,但它偶尔**只包最后
// 一段**,前面几段是裸文本用空行分隔。客户端的解析规则是「**只要存在完整 <p> 块,
// 就不再按空行回退切分**」—— 于是半包恰好落在唯一会塌的那一侧:整条消息被渲染成
// 一整坨,而不是多个气泡。反过来,全都不包反而正常(客户端会按空行拆开)。
//
// 为什么放在这里(而不是各写一套):网关(Web/REST/WS 回合,见
// internal/services/gateway/session_turn_reply.go)与渠道桥(IM 回合、主动推送,
// 见 internal/services/channelbridge/turnreply.go、proactive.go)都必须吃到**同一份**
// 规整后的文本 —— 落库内容、WS `message_persisted`、REST reply、续跑尾部、IM 出站
// 气泡切分(splitBubbles 按 <p> 切)全部以它为准。两份实现迟早漂移,而这一份正是
// 客户端「看到一段还是一坨」的唯一判据。
//
// 判据与边界(逐条对应下面的实现):
// - **幂等**(最重要的守门断言):已完全包裹的文本**逐字不变**。规整只做加法,
// 绝不二次加工好文本;段落之间的分隔空白(几个空行、缩进)原样保留。
// - 空文本 / 纯空白:原样返回(绝不制造空段落)。
// - 含 markdown 代码围栏(```):**整条跳过**。围栏内部按空行拆开会把一段代码
// 切成互不相连的段落(比不规整更糟),无法安全判定时宁可不碰。
// - `<action>` / `<narration>` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与
// IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** `<p>`。
// - `<p>` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
// - 收尾必过**标签配对修复**(paragraph_repair.go):落库/对外文本里不允许出现未配对
// 的段落标签(`<p><p>X</p>`、落单的开或闭),它们会被按「一个 `<p>` = 一段」合并
// 或补齐。该步同样幂等,且只增删标签本身,不吞正文。
var (
// atomRe 匹配文本里**不可再拆**的原子块:完整的 <p>…</p>(含自闭合 <p/>)与
// 完整的 <action>/<narration> 块。它们内部的空行不算段落分隔。
atomRe = regexp.MustCompile(`(?is)<p\b[^>]*?/>|<p\b[^>]*?>.*?</p\s*>` +
`|<action\b[^>]*?>.*?</action\s*>|<narration\b[^>]*?>.*?</narration\s*>`)
// paragraphBlockRe 只匹配完整的 <p>…</p> 块(含自闭合 <p/>),用于判断一段是否
// 已经被包裹。
paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?/>|<p\b[^>]*?>.*?</p\s*>`)
// blankLineRe 匹配分隔段落的**空行组**:两个及以上换行,中间只允许水平空白。
blankLineRe = regexp.MustCompile(`\n[ \t\r]*\n(?:[ \t\r]*\n)*`)
// codeFenceMarker 是 markdown 围栏代码块的起止标记(见到即整条跳过)。
codeFenceMarker = "```"
// trimCutset 是段落外缘的空白字符集,与 blankLineRe 的口径保持一致(空行组
// 只由这些字符构成),因此「外缘空白」与「段间空行」不会互相切错。
trimCutset = " \t\r\n\v\f"
)
// NormalizeParagraphs 返回规整后的助手文本:未被 `<p>` 包裹的段落补上 `<p>…</p>`,
// 已完整包裹的段落逐字保留,`<action>`/`<narration>` 段也包进它所在的 `<p>`;最后再
// 做一遍**标签配对修复**(未配对的段落标签按「一个 `<p>` = 一段」收干净,见
// paragraph_repair.go)。
//
// 它是纯函数且**幂等**(`NormalizeParagraphs(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
}
cuts := paragraphCuts(text)
if len(cuts) == 0 {
return repairParagraphTags(normalizePiece(text))
}
var b strings.Builder
b.Grow(len(text) + 3*len(cuts))
prev := 0
for _, cut := range cuts {
b.WriteString(normalizePiece(text[prev:cut]))
prev = cut
}
b.WriteString(normalizePiece(text[prev:]))
return repairParagraphTags(b.String())
}
// paragraphCuts 返回文本里全部的**分段边界**(切点,升序去重):
// - 空行组的末尾(段落分隔);
// - 每个完整 `<p>` 块的起点与终点(`<p>` 块自成一段,绝不被包进另一个 `<p>`)。
//
// 落在原子块(`<p>` 块 / `<action>` / `<narration>`)内部的空行**不是**边界 ——
// 这正是「不把标签对切开」的实现方式。
func paragraphCuts(text string) []int {
atoms := atomRe.FindAllStringIndex(text, -1)
cuts := make([]int, 0, 2*len(atoms)+4)
for _, loc := range blankLineRe.FindAllStringIndex(text, -1) {
if insideAtoms(atoms, loc) {
continue
}
cuts = append(cuts, loc[1])
}
for _, loc := range atoms {
if isParagraphBlock(text[loc[0]:loc[1]]) {
cuts = append(cuts, loc[0], loc[1])
}
}
sort.Ints(cuts)
out := make([]int, 0, len(cuts))
for _, c := range cuts {
if len(out) > 0 && c == out[len(out)-1] {
continue
}
out = append(out, c)
}
return out
}
// insideAtoms 报告 loc 是否完全落在某个原子块内部(原子块之间互不重叠,因此只需
// 逐个判断包含关系)。
func insideAtoms(atoms [][]int, loc []int) bool {
for _, a := range atoms {
if loc[0] >= a[0] && loc[1] <= a[1] {
return true
}
}
return false
}
// isParagraphBlock 报告 s 整体就是**一个**完整的 `<p>` 块(即该段已被包裹)。
func isParagraphBlock(s string) bool {
t := strings.TrimSpace(s)
if t == "" {
return false
}
loc := paragraphBlockRe.FindStringIndex(t)
return loc != nil && loc[0] == 0 && loc[1] == len(t)
}
// normalizePiece 规整相邻两个切点之间的一段原文。切点已经含**所有**位于原子块
// 之外的空行组,因此这一段**恰好就是一段**(它内部若还有空行,那只可能落在
// `<action>`/`<narration>` 块里,必须原样保留)。已是 `<p>` 块或纯空白时逐字返回;
// 否则整段包上 `<p>`,这一段自己的首尾空白(它同时是相邻 `<p>` 块之间的分隔)
// 原样保留。
//
// **例外(补闭不补开)**:段首已经是一个段落开标签(模型漏了闭标签,如 `<p>X`)时
// **只补 `</p>`、不再套外层 `<p>`** —— 再套一层就造出 `<p><p>X</p>`:客户端的非贪婪
// 契约会把 `<p>X` 当成气泡内容,用户直接看到字面的 `<p>`。这一处与 paragraph_repair.go
// 的配对修复是同一条根因的两半(详见该文件头)。
func normalizePiece(piece string) string {
if isParagraphBlock(piece) || strings.TrimSpace(piece) == "" {
return piece
}
lead := len(piece) - len(strings.TrimLeft(piece, trimCutset))
trail := len(piece) - len(strings.TrimRight(piece, trimCutset))
core := piece[lead : len(piece)-trail]
if strings.TrimSpace(core) == "" {
return piece
}
if startsWithParagraphOpen(core) {
return piece[:lead] + core + "</p>" + piece[len(piece)-trail:]
}
return piece[:lead] + "<p>" + core + "</p>" + piece[len(piece)-trail:]
}