Files
Cyrene_v2/internal/services/gateway/broadcast.go
T
AskaEth b36798265a docs(comments): proactive 判据键在 data 内、不在帧壳顶层(安卓核对指出)
4 处注释写「帧顶」而代码放在 data[proactive](+ 测试文件 6 处同源表述)。
安卓端两层都读所以不受影响,但注释与代码相反会误导下一个按注释写的人。
零行为改动(仅注释)。
2026-10-06 21:16:20 +08:00

108 lines
5.6 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 (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件归属 gateway 的下行实时推送:与 channel bridge 共用的
// 持久化消息帧,以及前端渲染的角色级状态帧(心情徽标、能力唤醒)。
// BroadcastPersisted 把一条刚刚持久化的聊天消息作为 WS 事件广播出去,
// 使同一账号的每个浏览器无需手动刷新即可追加该消息
// (design/16 §4.2)。它由 orchestrator(Web 轮次)与 channel bridge
// (IM 消息)共用,因此两条路径走同一种帧结构。事件类型按会话
// 区分:Web 主会话及其 Web 子会话广播 `message_persisted` 用于
// Web 聊天实时同步,而其他任何会话 id 都是隔离的渠道适配器会话
// (sess-xxx),广播 `channel_persisted` 供 IM 适配器日志页使用
// —— Web 聊天没有后者的处理器,因此在那里自然被忽略、永不污染
// 会话。Thinking 会被抹除,以与 WS 历史回放保持一致(design/16 §6)。
// 当 hub 接好广播时它是安全空操作。两种帧类型都携带消息内容,
// 因此 design/11 §7.1 帧门只把它们投递给 owner/admin 连接。
//
// 它**绝不**带 `proactive` 键:普通消息的帧的 `data` 里永远没有那个结构化判据,客户端因此
// 不可能把普通消息误判成主动消息去弹通知(切片 P0b 的「不误伤」那一条)。主动推送
// 那条路走 BroadcastProactivePersisted(proactive.go)。
func (s *Server) BroadcastPersisted(msg history.Message) {
s.broadcastPersisted(msg, false)
}
// broadcastPersisted 是上面两个入口共用的帧构造与扇出:proactive 决定帧的 `data` 里是否带上
// 「这是她主动说的话」的结构化判据 `proactive:true`(切片 P0b,见 proactive.go 的
// BroadcastProactivePersisted)。两条入口走**同一个**函数,因此帧类型推导、thinking
// 抹除与角色门(design/11 §7.1)逐字一致 —— 差别只有那一个键。
//
// 帧的 data 另带**可选**的 `unreadCount`(契约 v0.76):该会话此刻的绝对未读数,
// 与 GET /sessions 的 `unreadCount` 同一口径。它只在 >0 且真能算出来时出现(见
// unreadCountOf),缺席即老行为。Web 路径与 IM 路径共用本函数 ⇒ 两处口径天然一致,
// 不存在「Web 帧带、IM 帧不带」的分叉。
func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
wire := toHistoryMessage(msg)
wire.Thinking = ""
typ := "channel_persisted"
if msg.SessionID == history.MainSessionID || strings.HasPrefix(msg.SessionID, history.MainSessionID+":") {
typ = "message_persisted"
}
data := map[string]any{"message": wire}
if proactive {
data["proactive"] = true
}
// 未读数只在**确实算出来、且非零**时进帧(契约 v0.76)。缺席 = 老行为:老客户端
// 忽略未知键,新客户端落下这帧时角标已经是对的,不必再拉一次 GET /sessions。
// 不用 0 顶位:0 只能从「会话有游标」推出来,而算不出来(未接线/查询失败/
// 会话没有游标)时那个 0 就是编的(见 unreadCountOf)。
if n, ok := s.unreadCountOf(msg.SessionID); ok && n > 0 {
data["unreadCount"] = n
}
s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: data})
}
// wsCapacityAwaken 是能力状态迁移的 WS 事件类型(design/14 §5)。
// 其 data 字段是前端的 AwakenPayload {fromState,toState,tool}。
const wsCapacityAwaken = "capacity_awaken"
// BroadcastCapacityAwaken 把一次能力状态迁移作为 `capacity_awaken` 帧
// 扇出到 Web UI(design/14 §5)。sessionID 是发生迁移的会话,因此
// 客户端能定位到正确的会话,而不会把唤醒事件当成全局事件。data 与
// 前端 AwakenPayload {fromState, toState, tool} 对应;tool 可以为空
// (没有点名单个工具,客户端此时保留原有的工具徽标)。它不属于
// design/11 §7.1 的「状态机信号」(其 `tool` 指明的是被什么唤醒,而
// 渲染它的面板属于 owner 界面),因此帧门只把它投递给 owner/admin
// 连接。
func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) {
s.hub.broadcast(wsEvent{
Type: wsCapacityAwaken,
SessionID: sessionID,
Data: map[string]any{
"fromState": fromState,
"toState": toState,
"tool": tool,
},
})
}
// wsMoodEvent 是携带角色轮次结束后心情的 WS 事件类型(design/07 §3
// 下行 `mood`;design/01 §2 情绪状态机)。前端既有的 `mood` 处理器
// (ws.ts)读取 data.mood 并显示心情徽标。
const wsMoodEvent = "mood"
// BroadcastMood 把一次情绪变化作为 `mood` 帧扇出到 owner/admin 的 Web
// 客户端(design/07 §3)。角色心情/好感度属于私有状态,因此 design/11
// §7.1 帧门让它远离 viewer 连接。它仅在心情确实发生变化时被调用
// —— 组合根中的机制就是那道门 —— 因此徽标不会因为每轮都出现未变化
// 的值而闪烁。data 与前端 `mood` 处理器读取的内容对应({mood});
// affection/personaId 作为诚实的额外细节一同携带(affection 是该角色
// 当前好感度,personaId 是心情发生变化的角色),不渲染它们的客户端
// 会忽略之。心情是角色级而非会话级的值,因此不附加 sessionId。
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
s.hub.broadcast(wsEvent{
Type: wsMoodEvent,
Data: map[string]any{
"mood": mood,
"personaId": personaID,
"affection": affection,
},
})
}