package gateway import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // This file owns the gateway's outbound realtime pushes: the persisted-message // frames shared with the channel bridge plus the per-persona state frames the // frontend renders (mood badge, capacity awaken). // BroadcastPersisted broadcasts a just-persisted chat message as a WS event so // every browser of the same account appends it without a manual refresh // (design/16 §4.2). It is shared by the orchestrator (Web turns) and the channel // bridge (IM messages), so both paths flow through one frame shape. The event type // is split by session: the main web session and its web sub-sessions broadcast // `message_persisted` for web-chat realtime sync, while any other session id is an // isolated channel adapter session (sess-xxx) and broadcasts `channel_persisted` // for the IM adapter log page — the web chat has no handler for the latter, so it // is naturally ignored there and never pollutes the conversation. Thinking is // stripped to stay consistent with the WS history replay (design/16 §6). It is a // no-op-safe call when the hub has broadcast wired. 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 is the WS event type for a capacity state transition // (design/14 §5). Its data field is the frontend AwakenPayload {fromState,toState,tool}. const wsCapacityAwaken = "capacity_awaken" // BroadcastCapacityAwaken fans a capacity state transition to the Web UI as a // `capacity_awaken` frame (design/14 §5). sessionID is the conversation that // transitioned, so the client targets the correct session rather than treating the // wake event as global. data matches the frontend AwakenPayload {fromState, toState, // tool}; tool may be empty (no single tool was lit, the client then keeps the prior // tool badge). 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 is the WS event type carrying the persona's mood after a turn // (design/07 §3 下行 `mood`; design/01 §2 情绪状态机). The frontend's existing // `mood` handler (ws.ts) reads data.mood and shows the 心情 badge. const wsMoodEvent = "mood" // BroadcastMood fans one emotion change to every Web client as a `mood` frame // (design/07 §3). It is called only when the mood actually changed — the // machinery in the composition root is the gate — so the badge does not flicker // with an unchanged value on every turn. data matches what the frontend `mood` // handler reads ({mood}); affection/personaId ride along as the honest extra // detail (affection is the persona's current 好感度, personaId the persona whose // mood moved) and are ignored by clients that do not render them. The mood is a // persona-level value, not a per-session one, so no sessionId is attached. 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, }, }) }