Files
Cyrene_v2/internal/services/gateway/broadcast.go
T
AskaEth 5717735da9 feat(gateway): M44 实时帧与握手回放挂上 voice(纯增量)
修实根:`message_persisted` / `channel_persisted` 与握手 `history` 回放
此前只在 `toHistoryMessage` 之后直接扇出,于是消息 DTO 的语音面在这三条
面上恒缺席,客户端只能等下一次 REST 拉取才知道那是一条语音。

- `history_voice.go`:把语音补齐抽成存储无关的唯一收口点 `attachVoiceTo`
  (Server 与 hub 共用),并加 `voiceColumnsOf` 把任意历史视图窄化成读面,
  未实现该视图时为真 nil 接口(字段缺席,不 panic、不 500)。
- `broadcast.go`:新增 `persistedWire` —— thinking 抹除 + 可选 voice,
  `broadcastPersisted` 改用它;两类帧因此天然同口径。
- `ws_history.go`:握手回放同样补一次批量读列(最多 50 个 id 一条 SQL)。

成本实测:一帧 = 1 次 `IN (?)` 批量读列(1 个 id);回放一页 = 1 次
(去重后的全部 id);存储未提供该视图时 0 次。`history.Message` 没有
voice 列字段(`messageColumns` 也不含),故无法「零查询直接填」——补它
要改 internal/runtime/history 与 INSERT 写路径,超出本切片范围。

纯增量:voice 是可选键,非语音消息整键缺席(不是 null、不是 {});
帧壳、帧类型、REST DTO 形状一律未动。

测试(广播_voice_test.go):帧序列化输出上断言 ①②③④,并逐字比对
「帧/回放里的 voice」与「REST 读面同一行」;摘掉任一挂载即打红。
2026-10-26 18:46:06 +08:00

128 lines
6.9 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"
"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 帧不带」的分叉。帧内的 `message` 对象另带**可选**的 `voice`
// (契约 v0.67 §4.4;见 persistedWire):非语音消息整键缺席,因此老客户端逐字不变。
func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
wire := s.persistedWire(msg)
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})
}
// persistedWire 把一条待广播的消息投影成帧内的 `message` 对象:thinking 抹除(与 WS
// 历史回放一致,design/16 §6)+ 可选的语音面(契约 v0.67 §4.4)。
//
// 语音面与 REST 读面共用**同一个**收口点(attachVoiceTo → historyVoiceOf),因此
// 「同一行消息在帧里与 GET /sessions/{id}/messages 里」是逐字相同的对象,客户端不必
// 再拉一次 REST 才认出语音气泡。
//
// 代价(实测,见交付报告):每帧**一次**批量读列(`IN (?)` 一条 SQL,页内 id 去重),
// 与本路径既有的 unreadCountOf 查询同量级;存储未提供该视图时**零**查询、字段缺席。
// 之所以要查库而不是「从 history.Message 直接填」:那个结构体没有 voice 列字段
// (`messageColumns` 也不含它),补它要改 `internal/runtime/history` 与 INSERT 写路径
// ——那是本切片的范围之外(见交付报告的跨片接缝)。
func (s *Server) persistedWire(msg history.Message) historyMessage {
one := []historyMessage{toHistoryMessage(msg)}
one[0].Thinking = ""
attachVoiceTo(context.Background(), s.log, voiceColumnsOf(s.history), one)
return one[0]
}
// 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,
},
})
}