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" ) // This file wires the persona emotion state machine (design/01 §2: 情绪状态机 + // 好感度曲线 follow the persona/character, never a behavior mode) into the agent // loop's turn boundary. The loop stays emotion-agnostic: it reports each settled // turn and carries the returned mood label onto Result.Mood, which the // persistence layer writes onto the assistant message (design/16 §3.3). // // Scope of this milestone (A1) — what is wired end to end: // - every settled owner turn advances the bound persona's emotion machine and // affection (in-memory, persona-keyed by the registry's emotion Manager); // - the resulting mood rides the assistant message into chat_messages and the // `message_persisted` frame, and each *change* is broadcast as a `mood` frame. // // Not wired (design/01 does not specify it): the mood is not yet injected back // into the system prompt, so it does not steer the reply text, and the emotion // state is not persisted across restarts (design/01 requires neither; the state // resets to the persona's configured initialState on boot). // moodEmitter is the gateway-facing sink for `mood` broadcasts. *gateway.Server // implements it (BroadcastMood); held as an interface so the composition root // stays unit-testable with a fake sink, mirroring capacityEmitter. type moodEmitter interface { BroadcastMood(personaID, mood string, affection int) } // buildEmotionObserver assembles the persona emotion observer: the registry's // emotion Manager supplies each persona's machine and affection, the registry // supplies the persona's configured affection step (好感度曲线), and every change // is pushed to the Web UI through publishMood. func (a *app) buildEmotionObserver() *emotion.Observer { return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood) } // onTurnEnd is the loop's turn-boundary hook (design/01 §2): a settled turn is // applied to the persona the loop is bound to, and the mood after the turn is // returned for the assistant message. An absent persona id falls back to the // default persona, matching how the loop is bound. // // Two gates keep this to real conversation turns: // - only the user-facing conversation lane (the Web main session and its // sub-sessions; IM owner-private chat rides the main session, design/16 §4.4) // moves the mood. Autonomous and delegated loop calls — background thinking // (sess-backthink), sub-agent work, isolated IM sessions — are not turns of // the owner's conversation, so they must not rewrite her mood; // - a restricted (group/stranger) conversation is read-only: the observer // refuses the write, so a third party can never move her mood or affection // (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 reports whether a session belongs to the owner's live // conversation: the fixed main session "web" and its "web:" sub-sessions // (design/16 §2.1 — IM owner-private chat is routed into the main session). func isConversationLane(sessionID string) bool { return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":") } // publishMood broadcasts one emotion change as a `mood` frame (design/07 §3, // design/01 §2) so the Web 心情 badge follows the persona without a refresh. It is // called only when the mood actually changed, so the frame stays news rather than // per-turn noise. The sink is read at call time because the gateway is built after // the loop; before it exists the change is logged and dropped. 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) }