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:
1 parent
6ac214366e
commit
a19d12b228
7 files changed
+646
-60
No files matched your search
@@ -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 前端
|
||||
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。
|
||||
|
||||
Reference in new issue
Block a user