Files
AskaEth 1d3965aa80 fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)
M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
2026-10-24 19:40:30 +08:00

53 lines
2.7 KiB
Go

package gateway
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件承载主动推送的**落库**那一半(切片 P0「她主动找你」)。它从
// proactive.go 拆出来,好让「帧长什么样」与「记录怎么写」各自成篇
// (AGENTS §10 模块按职责拆源文件)。
//
// 为什么必须有这一半:修复前活跃客户端路由只把 `proactive_message` 帧
// 广播给在线连接(fire-and-forget),**一行都不写** ⇒ 刷新/重载后什么都补不回来,
// 界面既不显示气泡也补不出历史。主人的原话是「后台思考的主动推送一个都没推出来」。
// 主动消息是**她真实说过的一句话**,因此它必须与 IM 镜像路径走同一条去向:
// 主会话历史里的一条 assistant 消息。
// persistProactiveToMain 把一条主动消息写进主会话(`history.MainSessionID`),
// 并返回落库后的完整消息(含权威 id/时间戳),供帧载荷携带。
//
// 三条口径:
// - **角色恒为 assistant**:这是她单方面开口说的话,不是主人的输入 —— 塞成
// user 角色会污染「谁说了什么」,也会在历史里冒充主人发言(红线)。
// - **正文用调用方已经净化并规整过的文本**(backthink 的 stripInternalMarkers 出口,
// 再过 `turnreply.NormalizeParagraphs` —— 见 SendProactive 的「先规整、再落库」),
// 系统元数据(决策标记、渠道选择)绝不进正文,也绝不新增任何隐藏字段 ——
// 注入面因此与「气泡里看到的那句话」逐字相同。
// - **turn_id 留空**:主动推送不属于任何一路回合(她单方面开口,没有对应的
// 入站回合),空值诚实地表示这一点(与 channelbridge 的同一取舍)。
//
// 未接线历史存储时它是空操作并返回零值(`Persisted=false`)—— 与编排器 /
// IM 镜像路径同一取舍:没存储不是错误,但**绝不能谎称已落库**。写库失败返回
// 包装后的错误,由调用方让它可见(WARN + 返回给推送服务)。
func (h *hub) persistProactiveToMain(ctx context.Context, msg adapter.UnifiedMessage) (history.Message, error) {
if h.history == nil {
return history.Message{}, nil
}
full, err := h.history.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID,
Role: history.RoleAssistant,
Content: msg.Content,
Platform: msg.Platform,
Mode: modeChat,
})
if err != nil {
return history.Message{}, fmt.Errorf("gateway: persist proactive to main session: %w", err)
}
return full, nil
}