A1 情绪接线: - loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步 - persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700) - 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发 - cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd - 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟 - Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照) - 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环 拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批 回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
94 lines
4.6 KiB
Go
94 lines
4.6 KiB
Go
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:<uuid>" 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)
|
|
}
|