主人 2026-10-07:「对消息内的 <br> 始终处理为多条消息隔断」。 Web 侧(frontend/web): - chat.tsx 的 splitParagraphs(SplitChatBubble 的切分层)新增 BR_SEPARATOR_RE 与 splitBubbleBreaks:<br>/<br/>/</br> 等价、属性不解析、<brow>/<br2> 挡掉、 流式半截 <br 不切;不含 <br> 的段逐字返回原样(既有 p 口径一字节不动) - inlineMarkup.ts 删掉 break 节点(br 不再是行内元素),白名单收窄为 b/strong/i/em/code 五项;renderContent.tsx 同步;测试 76 -> 99 checks - 40 条基线逐字节对照:28 IDENTICAL,12 个 DIFF 恰是含 br/u/s/<BR> 的样例 IM 侧(internal/services/channelbridge): - 新增 inline_tags.go:成对平衡的白名单行内标签剥标签留文本、白名单外与 结构不平衡整段原样保留;br 作为第二级分隔符交给 splitBubbles - 两级切分:p 块为第一级,块内按 br 再切为第二级;空气泡丢弃 - br 切分放在角色注记摘除之后:否则 action 里的 br 会把注记正文漏成聊天气泡 (变异专门钉住这条顺序) 两路各 4 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
186 lines
8.1 KiB
Go
186 lines
8.1 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"math/rand"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 本文件承载聊天回复清洗器,用于在回复投递给
|
||
// IM 渠道适配器之前(design/17 §2 + 角色扮演约定:Web 客户端
|
||
// 把 <action>/<narration> 渲染为暗色斜体;IM 渠道只能收到纯聊天
|
||
// 文本,动作/独白标记必须移除)。
|
||
|
||
var (
|
||
// pairedRoleplayRe 匹配完整的 <action>...</action> / <narration>...</narration>
|
||
// 块(含内部文本),因此动作/独白会在投递给
|
||
// IM 前被整体丢弃。非贪婪且不区分大小写,以容忍模型写出 <Action>。
|
||
pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?</(action|narration)\s*>`)
|
||
// strayRoleplayRe 匹配任何残留的单个角色扮演开/闭标签
|
||
// (用于格式错误或未配对的情况)。
|
||
strayRoleplayRe = regexp.MustCompile(`(?is)</?(action|narration)\b[^>]*?>`)
|
||
// paragraphRe 匹配 <p>...</p> / <p/> 段落标记,保留内部文本。
|
||
paragraphRe = regexp.MustCompile(`(?is)</?p\b[^>]*?>`)
|
||
// blankLinesRe 把剥离后残留的连续空行折叠掉。
|
||
blankLinesRe = regexp.MustCompile(`\n\s*\n+`)
|
||
// starRoleplayRe 匹配全角星号包裹块 *...* ——这是模型忘记
|
||
// <action>/<narration> 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 能在中间插入 `<br>` 的分隔符切分(见 bubblesOfSegment)。
|
||
func stripNarration(text string) string {
|
||
if strings.TrimSpace(text) == "" {
|
||
return ""
|
||
}
|
||
return finishNarration(stripRoleplayMarkup(text))
|
||
}
|
||
|
||
// stripRoleplayMarkup 摘除角色扮演/动作/旁白标记:成对与落单的
|
||
// `<action>`/`<narration>`、全角星号块、带角色扮演关键词的半角星号块。
|
||
// 顺序与参数逐字沿用既有实现(本函数只是把原来的前四步搬出来,行为不变)。
|
||
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 完成一段**气泡文本**的清洗:抹平 `<p>` 段落标记、折叠连续空行、
|
||
// 剥掉行内白名单标签。这三步必须留在切分**之后**:段落与空行折叠是「一条消息内部」
|
||
// 的排版整理,行内标签的「整段降级」也以气泡为粒度(与 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 匹配整个 <p>...</p> 块并捕获其内部文本
|
||
// (设计:每个 <p> 即一个聊天气泡;Web 客户端各自渲染为一个
|
||
// 气泡,IM 适配器各自投递为一条消息)。
|
||
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
|
||
|
||
// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。
|
||
//
|
||
// **两个同级的分隔符**(契约 §6 v0.89):
|
||
// - 每个 `<p>...</p>` 块 = 一个气泡(既有语义,逐字不变);
|
||
// - 每个 `<br>` 类分隔符(`<br>` / `<br/>` / `</br>`,大小写不敏感)= 一次
|
||
// 「切成多条消息」的边界,与 `<p>` 同级 —— **不是**一个换行字符。
|
||
//
|
||
// 空气泡(含只由空白构成的气泡)被丢弃。没有 `<p>` 标记的回复**不是**按单个气泡处理
|
||
// 了(这一条被 v0.89 改写):它同样会按 `<br>` 切开,例如 `甲<br>乙` ⇒ 两条消息。
|
||
// 纯动作/独白得到空切片。
|
||
func splitBubbles(text string) []string {
|
||
if !strings.Contains(strings.ToLower(text), "<p") {
|
||
if c := bubblesOfSegment(text); len(c) != 0 {
|
||
return c
|
||
}
|
||
return nil
|
||
}
|
||
out := make([]string, 0)
|
||
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
|
||
out = append(out, bubblesOfSegment(m[1])...)
|
||
}
|
||
if len(out) == 0 {
|
||
// 有 <p> 标记但没有格式良好的块——回退为整段文本。
|
||
out = append(out, bubblesOfSegment(text)...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bubblesOfSegment 把一个**段落区段**(`<p>` 块内部文本,或无 `<p>` 时的整段文本)
|
||
// 切成它承载的若干气泡文本。
|
||
//
|
||
// 🔴 两步顺序是本文件的要害:
|
||
// 1. 先 `stripRoleplayMarkup` —— 角色扮演/星号标记整块消失。因此 `<br>` 写在
|
||
// `<action>` / 星号块**内部**时不会把一段注记劈成两条聊天消息(那样会把注记正文
|
||
// 漏成聊天气泡,是对既有「注记整体剥除」口径的回归);
|
||
// 2. 再按 `<br>` 切分 —— 此时剩下的 `<br>` 都只可能出现在**真正要对用户说的正文**里,
|
||
// 切开正合契约「始终处理为多条消息隔断」。
|
||
//
|
||
// 切分**之后**的每一段各自走 finishNarration(抹平 `<p>` / 折叠空行 / 剥行内标签),
|
||
// 因此「空气泡丢弃」与 `<p>` 空气泡同一套口径:只由空白构成的段会被 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 只按 `<br>` 类分隔符切分(分隔符本身丢弃),不做任何清洗。
|
||
// 没有分隔符时返回单元素切片,即「整段一个气泡」的既有语义。
|
||
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
|
||
}
|