Files
AskaEth a19d12b228 feat(markup): br 改为气泡/消息分隔符 + 行内白名单收窄到五项(Web + IM 两侧)
主人 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 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
2026-10-07 17:32:05 +08:00

186 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}