Files
Cyrene_v2/internal/services/gateway/proactive.go
T
AskaEth 84c124f5a9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 成功再打活跃 ⇒ 全部打红
2026-10-05 16:02:36 +08:00

121 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"fmt"
"time"
"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 事件类型
// (design/12 §3):Cyrene 在没有近期用户输入时决定发送的消息。
const wsProactiveMessage = "proactive_message"
// proactivePayload 是主动消息消毒后的线上结构。它只
// 携带客户端渲染气泡所需的字段(content、platform、channel、
// 发送者标签);它刻意省略 RawData / 媒体字节 / token,使
// 主动推送绝不可能把凭据或载荷泄露进前端。
type proactivePayload struct {
Platform string `json:"platform,omitempty"`
ChannelID string `json:"channelId,omitempty"`
ChannelType string `json:"channelType,omitempty"`
Content string `json:"content"`
ContentType string `json:"contentType,omitempty"`
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 连接,并返回投递与落库
// 两件事的结果(切片 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 backthink.ProactiveDelivery{}, err
}
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 backthink.ProactiveDelivery{Persisted: persisted.ID != ""},
fmt.Errorf("gateway: marshal proactive: %w", err)
}
h.mu.RLock()
defer h.mu.RUnlock()
n := 0
for c := range h.conns {
if !ownerAdminClaims(c.claims) {
continue
}
select {
case c.send <- b:
n++
default:
}
}
return backthink.ProactiveDelivery{Connections: n, Persisted: persisted.ID != ""}, persistErr
}
// Server.SendProactive 通过委托给 hub 满足后台思考的 ActiveClient
// 接缝,因此组合根能把网关自身交给汇聚点设置器。
func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) {
return s.hub.SendProactive(ctx, msg)
}
// proactivePayloadFrom 把 UnifiedMessage 投影为消毒后的线上载荷。
// persisted 是刚落库的主会话消息(未接线历史存储或落库失败时为零值),
// 它的 id/时间戳随之进入载荷,使客户端能把实时气泡对齐到历史行。
func proactivePayloadFrom(msg adapter.UnifiedMessage, persisted history.Message) proactivePayload {
return proactivePayload{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
ChannelType: string(msg.ChannelType),
Content: msg.Content,
ContentType: string(msg.ContentType),
Sender: msg.Sender.DisplayName,
Timestamp: msg.Timestamp,
MessageID: msg.MessageID,
ID: persisted.ID,
CreatedAt: persisted.CreatedAt,
}
}
// ownerAdminClaims 报告 claims 是否授权一次主动推送(owner 或
// admin)。nil claims(未认证 / 裸测试连接)绝不被作为目标。
func ownerAdminClaims(claims *auth.Claims) bool {
if claims == nil {
return false
}
return claims.Role == auth.RoleOwner || claims.Role == auth.RoleAdmin
}