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
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
)
|
||||
|
||||
// wsProactiveMessage 是自主主动推送的 WS 事件类型
|
||||
@@ -27,21 +29,46 @@ type proactivePayload struct {
|
||||
Sender string `json:"sender,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
// ID 是这条主动消息在**主会话历史**里的权威行 id(切片 P0「气泡恒落」)。
|
||||
// 客户端据此把实时气泡对齐到落库行:与握手回放 / REST 历史拉取按 id 去重
|
||||
// (同一条绝不渲染两个气泡),刷新后仍在。落库失败时该键**省略** ——
|
||||
// 客户端因此知道这条只有实时帧、没有历史行,而不是拿到一个悬空的 id。
|
||||
ID string `json:"id,omitempty"`
|
||||
// CreatedAt 是落库行的时间戳(Unix 毫秒),与 ID 同时出现/同时省略。它是
|
||||
// 权威的排序时间(与 `timestamp` 是同一次推送的两个视角:前者是历史行的
|
||||
// 时间,后者是这条统一消息自身的时间)。
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回
|
||||
// 有多少个接受了它。计数为零意味着没有 owner/admin 客户端在线,
|
||||
// 因此调用方回退到 IM bridge。只有 owner/admin 连接会被作为
|
||||
// 目标(红线:主动推送仅 owner);viewer 或未认证连接
|
||||
// 绝不被触达。它绝不阻塞:发送缓冲满时对该
|
||||
// 慢客户端丢弃该帧,而不是卡住后台思考推送。
|
||||
func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
|
||||
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回投递与落库
|
||||
// 两件事的结果(切片 P0,`backthink.ProactiveDelivery`)。
|
||||
//
|
||||
// 顺序是刻意的 —— **先落库、再发帧**:主动消息必须成为主会话
|
||||
// (`history.MainSessionID`)里的一条真实消息(assistant 角色),否则刷新/
|
||||
// 重载后什么都补不回来(这正是「气泡恒落」的根);而帧要带上落库行的 id,
|
||||
// 客户端才能把实时气泡对齐到历史行。
|
||||
//
|
||||
// 落库失败**不静默**:记一条带来源的 WARN,并把错误返回给调用方,同时
|
||||
// Persisted=false —— 帧照发(实时气泡不能丢),但记录里绝不说「已推送」。
|
||||
//
|
||||
// 计数为零意味着没有 owner/admin 客户端在线;消息此时仍会作为一条真实记录
|
||||
// 留在主会话里(下次打开就能看到),调用方据此回退到 IM bridge 把它**投出去**。
|
||||
// 只有 owner/admin 连接会被作为目标(红线:主动推送仅 owner);viewer 或未认证
|
||||
// 连接绝不被触达。它绝不阻塞:发送缓冲满时对该慢客户端丢弃该帧,
|
||||
// 而不是卡住后台思考推送。
|
||||
func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
return backthink.ProactiveDelivery{}, err
|
||||
}
|
||||
b, err := json.Marshal(wsEvent{Type: wsProactiveMessage, Data: proactivePayloadFrom(msg)})
|
||||
persisted, persistErr := h.persistProactiveToMain(ctx, msg)
|
||||
if persistErr != nil {
|
||||
h.log.Warn("persist proactive message to main session failed",
|
||||
"err", persistErr, "platform", msg.Platform)
|
||||
}
|
||||
b, err := json.Marshal(wsEvent{Type: wsProactiveMessage, Data: proactivePayloadFrom(msg, persisted)})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gateway: marshal proactive: %w", err)
|
||||
return backthink.ProactiveDelivery{Persisted: persisted.ID != ""},
|
||||
fmt.Errorf("gateway: marshal proactive: %w", err)
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -56,17 +83,19 @@ func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (in
|
||||
default:
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
return backthink.ProactiveDelivery{Connections: n, Persisted: persisted.ID != ""}, persistErr
|
||||
}
|
||||
|
||||
// Server.SendProactive 通过委托给 hub 满足后台思考的 ActiveClient
|
||||
// 接缝,因此组合根能把网关自身交给汇聚点设置器。
|
||||
func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
|
||||
func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) {
|
||||
return s.hub.SendProactive(ctx, msg)
|
||||
}
|
||||
|
||||
// proactivePayloadFrom 把 UnifiedMessage 投影为消毒后的线上载荷。
|
||||
func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload {
|
||||
// persisted 是刚落库的主会话消息(未接线历史存储或落库失败时为零值),
|
||||
// 它的 id/时间戳随之进入载荷,使客户端能把实时气泡对齐到历史行。
|
||||
func proactivePayloadFrom(msg adapter.UnifiedMessage, persisted history.Message) proactivePayload {
|
||||
return proactivePayload{
|
||||
Platform: msg.Platform,
|
||||
ChannelID: msg.ChannelID,
|
||||
@@ -76,6 +105,8 @@ func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload {
|
||||
Sender: msg.Sender.DisplayName,
|
||||
Timestamp: msg.Timestamp,
|
||||
MessageID: msg.MessageID,
|
||||
ID: persisted.ID,
|
||||
CreatedAt: persisted.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user