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 代码围栏(```):**整条跳过**。围栏内部按空行拆开会把一段代码
// 切成互不相连的段落(比不规整更糟),无法安全判定时宁可不碰。
// - ` `。
// - ` ` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
var (
// atomRe 匹配文本里**不可再拆**的原子块:完整的 … ]*?/>| ]*?>.*? … ]*?/>| ]*?>.*? ` 包裹的段落补上 ` … `。
//
// 它是纯函数且**幂等**(`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 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 b.String()
}
// paragraphCuts 返回文本里全部的**分段边界**(切点,升序去重):
// - 空行组的末尾(段落分隔);
// - 每个完整 ` ` 块的起点与终点(` ` 块自成一段,绝不被包进另一个 ` `)。
//
// 落在原子块(` ` 块 / ` ` 块(即该段已被包裹)。
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 规整相邻两个切点之间的一段原文。切点已经含**所有**位于原子块
// 之外的空行组,因此这一段**恰好就是一段**(它内部若还有空行,那只可能落在
// ` ` 块或纯空白时逐字返回;
// 否则整段包上 ` `,这一段自己的首尾空白(它同时是相邻 ` ` 块之间的分隔)
// 原样保留。
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
}
return piece[:lead] + " " + core + "