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 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
This commit is contained in:
AskaEth committed 2026-10-07 17:32:05 +08:00
1 parent 6ac214366e
commit a19d12b228
7 files changed
+646 -60

No files matched your search

+70 -12
View File
@@ -41,13 +41,23 @@ var (
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 {
@@ -56,9 +66,16 @@ func stripNarration(text string) string {
}
return m
})
s = halfStarRoleplayRe.ReplaceAllString(s, "")
s = paragraphRe.ReplaceAllString(s, "")
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)
}
@@ -93,31 +110,72 @@ func hasRoleplayPrefix(t string) bool {
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。
// 每个 <p>...</p> 块为一个气泡,其文本已剥离角色扮演/动作/
// 旁白;空气泡被丢弃。没有 <p> 标记的回复按
// 单个气泡处理(保留换行)。纯动作/独白得到空切片。
//
// **两个同级的分隔符**(契约 §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 := stripNarration(text); c != "" {
return []string{c}
if c := bubblesOfSegment(text); len(c) != 0 {
return c
}
return nil
}
out := make([]string, 0)
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
if c := stripNarration(m[1]); c != "" {
out = append(out, c)
}
out = append(out, bubblesOfSegment(m[1])...)
}
if len(out) == 0 {
// 有 <p> 标记但没有格式良好的块——回退为整段文本。
if c := stripNarration(text); c != "" {
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 前端
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。