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;纯动作/独白的 // 回复会变成空字符串(调用方可据此跳过发送)。 func stripNarration(text string) string { if strings.TrimSpace(text) == "" { return "" } 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 }) s = halfStarRoleplayRe.ReplaceAllString(s, "") s = paragraphRe.ReplaceAllString(s, "") s = blankLinesRe.ReplaceAllString(s, "\n") 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 把一条回复按顺序拆成它承载的聊天气泡列表。 // 每个

...

块为一个气泡,其文本已剥离角色扮演/动作/ // 旁白;空气泡被丢弃。没有

标记的回复按 // 单个气泡处理(保留换行)。纯动作/独白得到空切片。 func splitBubbles(text string) []string { if !strings.Contains(strings.ToLower(text), " 标记但没有格式良好的块——回退为整段文本。 if c := stripNarration(text); c != "" { out = append(out, c) } } return out } // bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时, // 使连发段落不会刷屏(design/17 §2)。Web 前端 // 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。 // 仅当一条回复承载多于一个气泡时才会用到它。 func bubbleGap() time.Duration { return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond }