fix(turnreply): 字面 \n 渲染成真换行(Web 镜像与 IM 出站同一份)

主人 2026-10-24 报障:QQ 群最新一条 assistant 消息里 6 处字面 `\n`
(反斜杠 + n,不是真换行)原样发出去,用户看到「F\n多利亚」。

落点选在**写入侧共享层** `turnreply.NormalizeParagraphs`(gateway 的
persistTurnReply 与 channelbridge 的 turnVisibleReply 都调它):
IM 的 deliverReply 拿同一个字符串既落库(= Web 主会话镜像)又发气泡,
Web chat 气泡是 whitespace-pre-wrap ⇒ 在共享层渲染,落库/WS/REST/IM
出站与历史一次性对齐;只在 channelbridge 清洗层改会留下「面板上仍是字面 \n」。

- 新增 internal/runtime/turnreply/literal_breaks.go 的 RenderLiteralLineBreaks:
  逐字节扫描,围栏(```)与行内代码(`…` / ``…``)内原样保留,`\n` 与
  `\t`/`\r`/`\uXXXX`、行尾孤立 `\` 一律不动;未闭合围栏保守保护到末尾;幂等。
- paragraph.go 在**围栏 skip 之前**调用它 ⇒ 含围栏的消息里围栏外的 `\n` 也渲染。
- 🔴 未改既有语义:只产出换行字符,绝不产出 <br>/<p> —— <br> 仍是气泡分隔符,
  字面 \n 只做气泡内换行(一条列表不会被拆成 7 条消息)。
- channelbridge 源码零改动。

验证:真机原文(6 处 \n / 5 个 <p>)修前红(摘掉钩子 3 个测试变红)→ 修后绿;
三处变异(去掉围栏保护 / 去掉 \ 配对 / 把渲染挪到围栏 skip 之后)各自变红后还原复绿。
全树 go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包)。
This commit is contained in:
AskaEth committed 2026-10-24 19:21:50 +08:00
1 parent 4ed5925296
commit fa573663c1
4 files changed
+504 -1

No files matched your search

@@ -0,0 +1,146 @@
package channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」在 **IM 侧真流水线**上的落点,
// 两件必须同时成立的事:
// 1. **落库(= Web 主会话镜像)与出站气泡吃同一份文本** —— 主人私聊的回复会镜像进
// Web 主会话,若只修出站,面板上仍会看到字面 `\n`(那正是这条修复的判据);
// 2. 🔴 **`<br>` 仍是气泡分隔符,字面 `\n` 只做气泡内换行** —— 把字面 `\n` 升级成
// 气泡切分会把一条列表拆成七条消息,比原缺陷更吵。
//
// 样本是**真机原文**(主人 2026-10-24 报障那条乐理回复,库里的 assistant 行,
// 6 处字面 `\n` 全挤在同一个 <p> 里)。
// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。
const literalEscape = `\n`
// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字节、5 个 <p>、
// 6 处字面 `\n`、不含反引号)。断言依赖这三条性质,改样本时必须同步。
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭</p>
<p>那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。</p>
<p>不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪</p>`
// TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条
// 回复的第三个 <p> 是一行字面 `F\n多利亚`;修复后同一个气泡里是**七行**,而落库
// 镜像与出站气泡是同一份文本。
func TestTurnReply_LiteralBreakRenderedForBothSides(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: realLiteralBreakReply}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
// ① 落库(= Web 主会话镜像):6 处字面 `\n` 全部变成真换行,段落结构一字不变。
want := strings.ReplaceAll(realLiteralBreakReply, literalEscape, "\n")
rec := assertAssistantReply(t, hist, want)
if got := strings.Count(rec.Content, literalEscape); got != 0 {
t.Fatalf("落库文本仍有 %d 处字面 `\\n` —— Web 面板上仍会显示 F\\n多利亚", got)
}
// 段落结构一字不变:仍是 5 个 <p>(字面 `\n` 绝不升级成段落或 <br>)。
if got := strings.Count(rec.Content, "<p>"); got != 5 {
t.Fatalf("落库文本 <p> 块 = %d, want 5(段落口径不得被改动)", got)
}
wantLines := strings.Count(realLiteralBreakReply, "\n") + 6
if got := strings.Count(rec.Content, "\n"); got != wantLines {
t.Fatalf("落库文本真换行 = %d, want %d(6 处字面 `\\n` 应各变成一个换行)", got, wantLines)
}
// ② 出站:5 条气泡(<p> 一级、<br> 二级的既有语义不变),第三条是七行。
if len(ad.sends) != 5 {
t.Fatalf("出站气泡 = %d 条, want 5(字面 `\\n` 绝不能切成消息): %+v", len(ad.sends), ad.sends)
}
scale := ad.sends[2].Content
lines := strings.Split(scale, "\n")
if len(lines) != 7 {
t.Fatalf("调式气泡 = %d 行, want 7(修前是 1 行字面 F\\n多利亚): %q", len(lines), scale)
}
if !strings.HasPrefix(lines[0], "伊奥尼亚:") || !strings.HasPrefix(lines[6], "洛克里亚:") {
t.Fatalf("调式气泡首尾行 = %q / %q, want 伊奥尼亚… / 洛克里亚…", lines[0], lines[6])
}
for i, sent := range ad.sends {
if strings.Contains(sent.Content, literalEscape) {
t.Fatalf("气泡[%d] 仍有字面 `\\n`: %q", i, sent.Content)
}
if strings.Contains(sent.Content, "<br") {
t.Fatalf("气泡[%d] 出现了 <br> —— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content)
}
}
// ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。
if !strings.Contains(rec.Content, scale) {
t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content)
}
}
// TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`<br>` 仍
// 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的
// 判据一个字节都没动。
func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + "乙<br>丙" + literalEscape + "丁"}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
assertAssistantReply(t, hist, "<p>甲\n乙<br>丙\n丁</p>")
if len(ad.sends) != 2 {
t.Fatalf("出站气泡 = %d 条, want 2(<br> 仍是消息分隔符): %+v", len(ad.sends), ad.sends)
}
if got, want := ad.sends[0].Content, "甲\n乙"; got != want {
t.Fatalf("气泡[0] = %q, want %q(气泡内换行)", got, want)
}
if got, want := ad.sends[1].Content, "丙\n丁"; got != want {
t.Fatalf("气泡[1] = %q, want %q(气泡内换行)", got, want)
}
}
// TestSendProactive_LiteralBreakRenderedForBothSides 覆盖**第三个**助手文本产出点:
// 主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此同一条缺陷在那
// 条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。
func TestSendProactive_LiteralBreakRenderedForBothSides(t *testing.T) {
t.Parallel()
const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"
weixin := &fakeAdapter{name: "weixin-ilink", connected: true}
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: acct}}}
hist := &captureHistory{}
b := newProactiveBridgeHist(t, owner, hist, weixin)
b.bubbleGapFn = func() time.Duration { return 0 }
push := "第一行" + literalEscape + "第二行"
if err := b.SendProactive(context.Background(), proactiveMsg(push)); err != nil {
t.Fatalf("SendProactive: %v", err)
}
if len(weixin.sends) != 1 {
t.Fatalf("weixin 气泡 = %d 条, want 1(字面 `\\n` 不切气泡): %+v", len(weixin.sends), weixin.sends)
}
if got, want := weixin.sends[0].Content, "第一行\n第二行"; got != want {
t.Fatalf("推送气泡 = %q, want %q", got, want)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
}
if got, want := recs[0].Content, "<p>第一行\n第二行</p>"; got != want {
t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want)
}
}