package gateway import ( "context" ) // wsReplayLimit 是回放给新接入客户端的最近消息条数。刻意取小值,使重连代价 // 低廉;完整活跃会话历史由前端走 REST 拉取(design/16 §4.1),回放只是让 // 一条全新连接立刻拿到上下文。 const wsReplayLimit = 50 // replayHistory 把 sessionID 最近的若干条消息作为 ws "history" 事件发给 // 单条刚注册的连接。 // // 回放携带主会话的消息正文,因此仅限 owner/admin(design/11 §7.2 A,主人于 // 2026-10-03 决策)。ownerAdminClaims 与主动推送用的是同一判据,所以 // 「谁可以在未被要求的情况下拿到主人的对话」只在一处回答,而不散落到各调用点。 // viewer 连接完成握手时完全不做回放:它的只读窗口是 REST 监控面 // (design/11 §7.1「语义澄清」),不是推送。 // // 门放在这里而不是 ws_hub_conn.go 里的 register/回放调用点,是刻意的: // 这是唯一发出回放的函数,此后任何调用者都无法绕开该检查。日后主人想让某个 // 角色也收到回放,必须先把它登记到 design/11 §7.2,与上行帧的做法完全一致 // (见 ws_route.go)。 // // 回放是尽力而为:store 为 nil 或读取出错都静默跳过。 // 回放不含 thinking(design/16 §6),因此该帧即使对 owner/admin // 也不会泄漏思维链。每条消息**可能**带可选 `voice`(契约 v0.67 §4.4), // 且与 REST 读面同一份投影 —— 它的缺席语义同样是「这条消息不是语音」 // (非语音消息整键缺席),因此回放对老客户端仍是同一份形状。 func (h *hub) replayHistory(c *wsConn, sessionID string) { if !ownerAdminClaims(c.claims) { return } if h.history == nil { return } msgs, err := h.history.Recent(context.Background(), sessionID, wsReplayLimit) if err != nil { h.log.Warn("history replay", "err", err, "session", sessionID) return } out := make([]historyMessage, 0, len(msgs)) for _, m := range msgs { wire := toHistoryMessage(m) wire.Thinking = "" out = append(out, wire) } // 语音面(契约 v0.67 §4.4):回放与 REST 读面共用**同一个**收口点与同一份投影, // 因此刚连上的客户端就能按 id 认出语音气泡,不必等下一次 REST 拉取。一次批量读列 // (最多 wsReplayLimit 条 id 一条 SQL),不是逐条查;存储没提供该视图 / 读失败时 // 只让该字段缺席,回放本身照常发出(尽力而为,与上面的 Recent 同一纪律)。 attachVoiceTo(context.Background(), h.log, voiceColumnsOf(h.history), out) c.enqueueJSON(wsEvent{Type: "history", SessionID: sessionID, Data: map[string]any{"messages": out}}) }