主人 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 包)。
162 lines
7.8 KiB
Go
162 lines
7.8 KiB
Go
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:]
|
||
}
|