package gateway import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件拥有编排器的聊天历史持久化钩子(design/16 // §3.3):用户消息流在提交时写入,助手回复在 // 回合结算时写入。未接线 history 存储时二者都是静默空操作, // 因此编排器无存储也能原样运行(测试 / 未接线的网关)。 // modeChat 是回合未携带行为模式时所持久化的模式(design/01 §3, // design/16 §5.1)。它也是 IM 路径唯一会写入的模式,因为 IM // 只携带聊天文本。 const modeChat = "chat" // modeOrDefault 返回 mode,调用方未提供时返回 modeChat,使 // 持久化消息绝不携带空的行为模式(design/16 §5.1)。 func modeOrDefault(mode string) string { if mode == "" { return modeChat } return mode } // messageModeContextKey 是携带回合行为模式的 context 键 // (design/01 §3、design/16 §5.1),从 HTTP/WS 边界引入编排器的 // 持久化,使每条持久化消息被它发送时的模式标注。 type messageModeContextKey struct{} // withMode 返回携带 mode 的 context,使编排器能标注 // 持久化的用户消息与助手回复。空 mode 是空操作 // (持久化层回退到 modeChat)。 func withMode(ctx context.Context, mode string) context.Context { return context.WithValue(ctx, messageModeContextKey{}, mode) } // modeFrom 返回 ctx 携带的行为模式,未设置时返回 ""。 func modeFrom(ctx context.Context) string { v, _ := ctx.Value(messageModeContextKey{}).(string) return v } // persistUserMessages 把一次已提交回合中每条 RoleUser 消息写入 // 历史存储,使原始用户消息流在回合结算前就持久,随后 // 通知传输层,使同一账号的每个浏览器都能追加它 // 而无需手动刷新(design/16 §3.3、§4.2)。deviceID 用发送客户端标注 // 消息(design/16 §4.5),mode 是该回合发送时的行为模式 // (design/16 §5.1),为空时回退到 modeChat,而 personaID 是 // 该回合说话时的角色(design/22 §7-5)—— 即当时的角色,因此 // 之后切换角色的会话仍保有准确的「谁说了这个」流。未接线 // history 存储时它是空操作。被拒绝的回合(空、待处理已满) // 绝不触达它,因此不会持久化任何空内容。 func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) { if o.history == nil { return } for _, m := range messages { if m.Role != model.RoleUser { continue } text, image := messageTextImage(m) if text == "" && image == "" { continue } full, err := o.history.AppendFull(ctx, history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}) if err != nil { o.log.Error("orchestrator persist user", "err", err, "session", sessionID, "device_id", deviceID) continue } o.notifyPersisted(full) } } // persistAssistantReply 把已结算回合的最终助手文本写入 // 历史存储,然后通知传输层(design/16 §3.3、§4.2)。deviceID 用 // 发送该回合的客户端标注回复(design/16 §4.5),mode 是 // 该回合的行为模式(design/16 §5.1),为空时回退到 modeChat, // 而 personaID 是产出该回复的角色(design/22 §7-5)。Mood // 是该回合中人格的 mood(design/01 §2 情绪状态机),取自 loop // 结果,因此已存/DTO/`message_persisted` 的 mood 是真实数据,而不是 // 永远为空的列;不跟踪 mood 的回合存一个空 mood。它 // 在未接线 history 存储,或该回合没有产出文本 // 时是空操作(被中断/出错的回合保持 res 为 nil)。该写入在一个全新的 // 后台 context 下运行,因此被取消的回合 context 绝不可能中断它。 func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode, personaID string) { if o.history == nil || res == nil || res.Text == "" { return } full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}) if err != nil { o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID) return } o.notifyPersisted(full) } // messageTextImage 返回消息内容部分拼接后的文本与首个图片 // URL,供持久化进 chat_messages 各列。 func messageTextImage(m model.Message) (text, image string) { for _, c := range m.Content { if c.Type == model.ContentTypeText { text += c.Text } else if c.Type == model.ContentTypeImage && image == "" { image = c.ImageURL } } return text, image }