package channelbridge import ( "math/rand" "regexp" "strings" "time" "unicode/utf8" ) // 本文件承载聊天回复清洗器,用于在回复投递给 // IM 渠道适配器之前(design/17 §2 + 角色扮演约定:Web 客户端 // 把 / 渲染为暗色斜体;IM 渠道只能收到纯聊天 // 文本,动作/独白标记必须移除)。 var ( // pairedRoleplayRe 匹配完整的 ... / ... // 块(含内部文本),因此动作/独白会在投递给 // IM 前被整体丢弃。非贪婪且不区分大小写,以容忍模型写出 。 pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?`) // strayRoleplayRe 匹配任何残留的单个角色扮演开/闭标签 // (用于格式错误或未配对的情况)。 strayRoleplayRe = regexp.MustCompile(`(?is)]*?>`) // paragraphRe 匹配

...

/

段落标记,保留内部文本。 paragraphRe = regexp.MustCompile(`(?is)]*?>`) // blankLinesRe 把剥离后残留的连续空行折叠掉。 blankLinesRe = regexp.MustCompile(`\n\s*\n+`) // starRoleplayRe 匹配全角星号包裹块 *...* ——这是模型忘记 // / XML 标签时的漂移形式。正文被 // 捕获,以便非角色扮演的全角强调可以原样保留。 starRoleplayRe = regexp.MustCompile(`(?s)*([\s\S]*?)*`) // halfStarRoleplayRe 匹配明确带有角色扮演 // 关键词(动作/旁白/内心/神态/轻声)的半角星号块,因此 *动作:弯起眼睛* 会被剥离, // 而普通的 markdown *emphasis* 保持不动。 halfStarRoleplayRe = regexp.MustCompile(`(?s)\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*`) ) // shortRoleplayRunes 限定无关键词的全角星号块最长可以是多少 // 仍算作角色扮演批注(design §9.5:保守起见——长的 // 无关键词 *...* 段落保留为文本,而不整体丢弃)。 const shortRoleplayRunes = 40 // stripNarration 返回去掉角色扮演/动作/ // 旁白标记、段落标记被抹平、连续空行被折叠、行内白名单标签被剥掉的 // 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的 // 回复会变成空字符串(调用方可据此跳过发送)。 // // 它是 finishNarration ∘ stripRoleplayMarkup 两步的组合 —— 拆成两步是为了让 // splitBubbles 能在中间插入 `
` 的分隔符切分(见 bubblesOfSegment)。 func stripNarration(text string) string { if strings.TrimSpace(text) == "" { return "" } return finishNarration(stripRoleplayMarkup(text)) } // stripRoleplayMarkup 摘除角色扮演/动作/旁白标记:成对与落单的 // ``/``、全角星号块、带角色扮演关键词的半角星号块。 // 顺序与参数逐字沿用既有实现(本函数只是把原来的前四步搬出来,行为不变)。 func stripRoleplayMarkup(text string) string { s := pairedRoleplayRe.ReplaceAllString(text, "") s = strayRoleplayRe.ReplaceAllString(s, "") s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string { if isStarRoleplay(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(m, "*"), "*"))) { return "" } return m }) return halfStarRoleplayRe.ReplaceAllString(s, "") } // finishNarration 完成一段**气泡文本**的清洗:抹平 `

` 段落标记、折叠连续空行、 // 剥掉行内白名单标签。这三步必须留在切分**之后**:段落与空行折叠是「一条消息内部」 // 的排版整理,行内标签的「整段降级」也以气泡为粒度(与 Web 端同口径)。 func finishNarration(segment string) string { s := paragraphRe.ReplaceAllString(segment, "") s = blankLinesRe.ReplaceAllString(s, "\n") s = stripInlineTags(s) return strings.TrimSpace(s) } // isStarRoleplay 报告全角星号块 *...* 是否承载角色扮演 // 批注:它带角色扮演关键词,或者是短的独立片段 // (无换行、无长正文)——即漂移形态 *弯起眼睛*。长的无关键词 // 星号包裹段落会保留,以免丢掉真实消息。 func isStarRoleplay(inner string) bool { t := strings.TrimSpace(inner) if t == "" { return false } if hasRoleplayPrefix(t) { return true } return !strings.Contains(t, "\n") && utf8.RuneCountInString(t) <= shortRoleplayRunes } // hasRoleplayPrefix 报告该块是否以角色扮演关键词开头。 func hasRoleplayPrefix(t string) bool { for _, p := range []string{"动作", "旁白", "内心", "神态", "轻声"} { if strings.HasPrefix(t, p) { return true } } return false } // paragraphBlockRe 匹配整个

...

块并捕获其内部文本 // (设计:每个

即一个聊天气泡;Web 客户端各自渲染为一个 // 气泡,IM 适配器各自投递为一条消息)。 var paragraphBlockRe = regexp.MustCompile(`(?is)]*?>(.*?)`) // splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。 // // **两个同级的分隔符**(契约 §6 v0.89): // - 每个 `

...

` 块 = 一个气泡(既有语义,逐字不变); // - 每个 `
` 类分隔符(`
` / `
` / `
`,大小写不敏感)= 一次 // 「切成多条消息」的边界,与 `

` 同级 —— **不是**一个换行字符。 // // 空气泡(含只由空白构成的气泡)被丢弃。没有 `

` 标记的回复**不是**按单个气泡处理 // 了(这一条被 v0.89 改写):它同样会按 `
` 切开,例如 `甲
乙` ⇒ 两条消息。 // 纯动作/独白得到空切片。 func splitBubbles(text string) []string { if !strings.Contains(strings.ToLower(text), " 标记但没有格式良好的块——回退为整段文本。 out = append(out, bubblesOfSegment(text)...) } return out } // bubblesOfSegment 把一个**段落区段**(`

` 块内部文本,或无 `

` 时的整段文本) // 切成它承载的若干气泡文本。 // // 🔴 两步顺序是本文件的要害: // 1. 先 `stripRoleplayMarkup` —— 角色扮演/星号标记整块消失。因此 `
` 写在 // `` / 星号块**内部**时不会把一段注记劈成两条聊天消息(那样会把注记正文 // 漏成聊天气泡,是对既有「注记整体剥除」口径的回归); // 2. 再按 `
` 切分 —— 此时剩下的 `
` 都只可能出现在**真正要对用户说的正文**里, // 切开正合契约「始终处理为多条消息隔断」。 // // 切分**之后**的每一段各自走 finishNarration(抹平 `

` / 折叠空行 / 剥行内标签), // 因此「空气泡丢弃」与 `

` 空气泡同一套口径:只由空白构成的段会被 TrimSpace 成空串后丢掉。 func bubblesOfSegment(segment string) []string { parts := splitByBreakTags(stripRoleplayMarkup(segment)) out := make([]string, 0, len(parts)) for _, p := range parts { if c := finishNarration(p); c != "" { out = append(out, c) } } return out } // splitByBreakTags 只按 `
` 类分隔符切分(分隔符本身丢弃),不做任何清洗。 // 没有分隔符时返回单元素切片,即「整段一个气泡」的既有语义。 func splitByBreakTags(segment string) []string { spans := breakSeparatorSpans(segment) if len(spans) == 0 { return []string{segment} } parts := make([]string, 0, len(spans)+1) last := 0 for _, sp := range spans { parts = append(parts, segment[last:sp[0]]) last = sp[1] } return append(parts, segment[last:]) } // bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时, // 使连发段落不会刷屏(design/17 §2)。Web 前端 // 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。 // 仅当一条回复承载多于一个气泡时才会用到它。 func bubbleGap() time.Duration { return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond }