package turnreply import ( "regexp" "sort" "strings" ) // 本文件承载助手文本的**段落包裹规整**:把「只包了最后一段」的半包回复,在写入 // 侧规整成「每个段落一个

…

」的规范形态。 // // 缺陷形状(安卓侧实测):模型的多数回复按段产出 `

…

`,但它偶尔**只包最后 // 一段**,前面几段是裸文本用空行分隔。客户端的解析规则是「**只要存在完整

块, // 就不再按空行回退切分**」—— 于是半包恰好落在唯一会塌的那一侧:整条消息被渲染成 // 一整坨,而不是多个气泡。反过来,全都不包反而正常(客户端会按空行拆开)。 // // 为什么放在这里(而不是各写一套):网关(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 按

切)全部以它为准。两份实现迟早漂移,而这一份正是 // 客户端「看到一段还是一坨」的唯一判据。 // // 判据与边界(逐条对应下面的实现): // - **幂等**(最重要的守门断言):已完全包裹的文本**逐字不变**。规整只做加法, // 绝不二次加工好文本;段落之间的分隔空白(几个空行、缩进)原样保留。 // - 空文本 / 纯空白:原样返回(绝不制造空段落)。 // - 含 markdown 代码围栏(```):**整条跳过**。围栏内部按空行拆开会把一段代码 // 切成互不相连的段落(比不规整更糟),无法安全判定时宁可不碰。 // - `` / `` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与 // IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** `

`。 // - `

` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。 // - 收尾必过**标签配对修复**(paragraph_repair.go):落库/对外文本里不允许出现未配对 // 的段落标签(`

X

`、落单的开或闭),它们会被按「一个 `

` = 一段」合并 // 或补齐。该步同样幂等,且只增删标签本身,不吞正文。 var ( // atomRe 匹配文本里**不可再拆**的原子块:完整的

…

(含自闭合

)与 // 完整的 / 块。它们内部的空行不算段落分隔。 atomRe = regexp.MustCompile(`(?is)]*?/>|]*?>.*?` + `|]*?>.*?|]*?>.*?`) // paragraphBlockRe 只匹配完整的

…

块(含自闭合

),用于判断一段是否 // 已经被包裹。 paragraphBlockRe = regexp.MustCompile(`(?is)]*?/>|]*?>.*?`) // blankLineRe 匹配分隔段落的**空行组**:两个及以上换行,中间只允许水平空白。 blankLineRe = regexp.MustCompile(`\n[ \t\r]*\n(?:[ \t\r]*\n)*`) // codeFenceMarker 是 markdown 围栏代码块的起止标记(见到即整条跳过)。 codeFenceMarker = "```" // trimCutset 是段落外缘的空白字符集,与 blankLineRe 的口径保持一致(空行组 // 只由这些字符构成),因此「外缘空白」与「段间空行」不会互相切错。 trimCutset = " \t\r\n\v\f" ) // NormalizeParagraphs 返回规整后的助手文本:未被 `

` 包裹的段落补上 `

…

`, // 已完整包裹的段落逐字保留,``/`` 段也包进它所在的 `

`;最后再 // 做一遍**标签配对修复**(未配对的段落标签按「一个 `

` = 一段」收干净,见 // paragraph_repair.go)。 // // 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) == // NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。 func NormalizeParagraphs(text string) string { 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 返回文本里全部的**分段边界**(切点,升序去重): // - 空行组的末尾(段落分隔); // - 每个完整 `

` 块的起点与终点(`

` 块自成一段,绝不被包进另一个 `

`)。 // // 落在原子块(`

` 块 / `` / ``)内部的空行**不是**边界 —— // 这正是「不把标签对切开」的实现方式。 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 整体就是**一个**完整的 `

` 块(即该段已被包裹)。 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 规整相邻两个切点之间的一段原文。切点已经含**所有**位于原子块 // 之外的空行组,因此这一段**恰好就是一段**(它内部若还有空行,那只可能落在 // ``/`` 块里,必须原样保留)。已是 `

` 块或纯空白时逐字返回; // 否则整段包上 `

`,这一段自己的首尾空白(它同时是相邻 `

` 块之间的分隔) // 原样保留。 // // **例外(补闭不补开)**:段首已经是一个段落开标签(模型漏了闭标签,如 `

X`)时 // **只补 `

`、不再套外层 `

`** —— 再套一层就造出 `

X

`:客户端的非贪婪 // 契约会把 `

X` 当成气泡内容,用户直接看到字面的 `

`。这一处与 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 + "

" + piece[len(piece)-trail:] } return piece[:lead] + "

" + core + "

" + piece[len(piece)-trail:] }