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 连接。 func (s *Server) BroadcastPersisted(msg history.Message) { wire := toHistoryMessage(msg) wire.Thinking = "" typ := "channel_persisted" if msg.SessionID == history.MainSessionID || strings.HasPrefix(msg.SessionID, history.MainSessionID+":") { typ = "message_persisted" } s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: map[string]any{"message": wire}}) } // 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, }, }) }