fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可): - 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来 - 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知) 后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库) 载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」 前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级) + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表) + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result) + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑) 双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次 契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
This commit is contained in:
1 parent
1bf85a955b
commit
84c124f5a9
18 files changed
+1545
-46
No files matched your search
@@ -69,13 +69,35 @@ func (p PushResult) String() string {
|
||||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
|
||||
}
|
||||
|
||||
// ProactiveDelivery 是一次活跃客户端投递的结果(切片 P0「她主动找你」)。
|
||||
// 它把**两件本就独立**的事分开报告:帧送到了几条连接,以及这条消息有没有
|
||||
// 成为主会话里的一条真实消息。
|
||||
//
|
||||
// 为什么要分开:修复前活跃客户端路由只广播 WS 帧、不落库,于是面板上的
|
||||
// 「delivered to 2 active client(s)」被读成「已推送」,而刷新后什么都没剩
|
||||
// —— 那正是主人报的「后台思考的主动推送一个都没有推出来」。落库与投递各自
|
||||
// 成败,记录里就必须各自可见。
|
||||
type ProactiveDelivery struct {
|
||||
// Connections 是接受该帧的在线 owner/admin 连接数。0 意味着此刻没有
|
||||
// 在线目标;它**不代表消息丢了**——消息可能已经落库(见 Persisted)。
|
||||
Connections int
|
||||
// Persisted 报告该消息是否已成为主会话历史(`history.MainSessionID`)里的
|
||||
// 一条真实消息,因而刷新/重载后仍在。
|
||||
Persisted bool
|
||||
}
|
||||
|
||||
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
|
||||
// 客户端,并报告有多少个收到。它是首选的推送目标。
|
||||
// 客户端,并报告投递与落库两件事。它是首选的推送目标。
|
||||
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
|
||||
type ActiveClient interface {
|
||||
// SendProactive 把 msg 推给每个活跃客户端,并返回
|
||||
// 接受它的客户端数量。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
|
||||
// ProactiveDelivery。
|
||||
//
|
||||
// 实现契约(切片 P0):**先落库、再发帧**,且发帧的载荷带上落库行的 id ——
|
||||
// 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在)。落库失败时
|
||||
// 仍要发出帧(实时气泡不能丢),但必须把错误返回且 Persisted=false,
|
||||
// 让调用方如实记录「发出去了、但没留下记录」。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ProactiveDelivery, error)
|
||||
}
|
||||
|
||||
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
|
||||
@@ -246,19 +268,43 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
|
||||
}()
|
||||
}
|
||||
|
||||
// pushActive 只投递给活跃 Web/原生客户端。
|
||||
// pushActive 只投递给活跃 Web/原生客户端。它按 ProactiveDelivery 如实报告
|
||||
// 「送到了几条连接」与「有没有落进主会话」(切片 P0):投递数为 0 时消息仍可能
|
||||
// 已经是一条真实记录,两者绝不互相冒充。
|
||||
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.active != nil {
|
||||
n, err := p.active.SendProactive(ctx, msg)
|
||||
if err != nil {
|
||||
p.log.Warn("backthink active client push failed", "error", err)
|
||||
} else if n > 0 {
|
||||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: fmt.Sprintf("delivered to %d active client(s)", n)}
|
||||
}
|
||||
if p.active == nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
d, err := p.active.SendProactive(ctx, msg)
|
||||
if err != nil {
|
||||
p.log.Warn("backthink active client push failed", "error", err)
|
||||
}
|
||||
if d.Connections > 0 {
|
||||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: activeDeliveryDetail(d)}
|
||||
}
|
||||
if d.Persisted {
|
||||
// 此刻没有在线客户端,但这条话已经成为主会话里的一条真实记录:打开/
|
||||
// 刷新页面就能看到,只是没有实时帧可发。如实分别报告这两件事,面板才不会
|
||||
// 把「没推出去」与「没留下」混为一谈。
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client, recorded in main session"}
|
||||
}
|
||||
if err != nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client", Err: err.Error()}
|
||||
}
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
|
||||
// activeDeliveryDetail 是活跃客户端路由的 Detail 措辞:投递数与「有没有落进
|
||||
// 主会话」两件事实都在里面,且**绝不**只说「已推送」(那正是被误读成「刷新还在」
|
||||
// 的原话)。
|
||||
func activeDeliveryDetail(d ProactiveDelivery) string {
|
||||
state := "not persisted to main session"
|
||||
if d.Persisted {
|
||||
state = "persisted to main session"
|
||||
}
|
||||
return fmt.Sprintf("delivered to %d active client(s), %s", d.Connections, state)
|
||||
}
|
||||
|
||||
// pushIM 只投递给 IM 桥。
|
||||
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.im == nil {
|
||||
|
||||
Reference in new issue
Block a user