package main import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // 本文件把 persona 情绪状态机(design/01 §2:情绪状态机 + 好感度曲线跟随 // persona/角色,绝不跟随行为模式)接到 agent 循环的轮次边界上。循环保持与情绪无关: // 它上报每个已结算轮次,并把返回的心情标签带到 Result.Mood,由持久化层写到助手消息上 // (design/16 §3.3)。 // // 本里程碑(A1)端到端接通的范围: // - 每个已结算的主人轮次推进所绑定 persona 的情绪状态机与好感度(内存态,按 persona // 为键,由 registry 的 emotion Manager 持有); // - 由此得到的心情随助手消息进入 chat_messages 与 `message_persisted` 帧,而每次 // *变化* 都作为 `mood` 帧广播。 // // 未接通(design/01 未作规定):心情尚未反注入 system 提示,因此不会左右回复文本; // 情绪状态也不跨重启持久化(design/01 两者都不要求;启动时状态重置为 persona 配置的 // initialState)。 // moodEmitter 是面向 gateway 的 `mood` 广播出口。*gateway.Server 实现它 // (BroadcastMood);这里持有接口是为了让组合根能用假实现做单元测试,与 // capacityEmitter 一致。 type moodEmitter interface { BroadcastMood(personaID, mood string, affection int) } // buildEmotionObserver 装配 persona 情绪观察者:registry 的 emotion Manager 提供 // 每个 persona 的状态机与好感度,registry 提供 persona 配置的好感度步长 // (好感度曲线),每次变化都经 publishMood 推送到 Web UI。 func (a *app) buildEmotionObserver() *emotion.Observer { return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood) } // onTurnEnd 是循环的轮次边界钩子(design/01 §2):已结算的轮次被施加到循环所绑定的 // persona 上,并把该轮之后的心情返回给助手消息。persona id 缺失时退回默认 persona, // 与循环的绑定方式一致。 // // 两道闸门确保只有真实对话轮次才触发: // - 只有面向用户的对话车道(Web 主会话及其子会话;IM 主人私聊走主会话, // design/16 §4.4)会推动心情。自主与委派的循环调用 —— 后台思考(sess-backthink)、 // 子代理工作、隔离的 IM 会话 —— 不是主人对话的轮次,因此不得改写她的心情; // - 受限(群聊/陌生人)对话是只读的:观察者拒绝写入,因此第三方永远无法左右她的 // 心情或好感度(design/02 §3、design/11 §2)。 func (a *app) onTurnEnd(end loop.TurnEnd) string { if a.emotion == nil || !isConversationLane(end.SessionID) { return "" } personaID := end.PersonaID if personaID == "" { personaID = a.defaultPersonaID() } return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted())) } // isConversationLane 报告某会话是否属于主人的实时对话:固定的主会话 "web" 及其 // "web:" 子会话(design/16 §2.1 —— IM 主人私聊被路由进主会话)。 func isConversationLane(sessionID string) bool { return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":") } // publishMood 把一次情绪变化作为 `mood` 帧广播(design/07 §3、design/01 §2), // 使 Web 的心情徽标无需刷新即可跟随 persona。它只在心情确实变化时调用,因此该帧 // 是新闻而不是每轮噪声。出口在调用时读取,因为 gateway 构建在循环之后;它还不存在 // 时该变化只记日志并丢弃。 func (a *app) publishMood(update emotion.Update) { log := logger.WithSource(a.log, "cmd/cyrene") if a.moodSink == nil { log.Debug("mood change dropped: gateway not ready", "persona", update.PersonaID, "from", string(update.Before), "to", string(update.After), "affection", update.Affection, "signal", update.Signal) return } log.Info("persona mood changed", "persona", update.PersonaID, "from", string(update.Before), "to", string(update.After), "affection", update.Affection, "signal", update.Signal) a.moodSink.BroadcastMood(update.PersonaID, string(update.After), update.Affection) }