Files
Cyrene_v2/cmd/cyrene/emotion.go
T
AskaEth 52f08dc343
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(cyrene): cmd/cyrene 存量英文注释中文化(22 个非测试文件,仅注释)
- 四组并行完成:main/wire*.go、wire_capacity|channels|runtime、channels|persona_*|emotion|observability、
  voice|plugins|stream|skills|vision|subagent_tool|extractor|mcp|sessionread_tool|terminal_tool。
- 机械校验(git diff -U0 去 // 行与空行)仅剩 wire_frontend.go:84 一处行尾注释:代码前缀
  「\ta.gw = gw 」经 cat -A 与 HEAD 逐字节比对一致(allow 清单已声明)。
- 验收:隔离 worktree(HEAD + 本批 patch)go build/vet 绿、全树 58 包 ok、
  linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净。
- 备注:整树两次红均为负载敏感 flake(backthink / orchestration 的 timeout 类用例单独 -count=20 全过)。
2026-10-04 16:58:06 +08:00

85 lines
4.4 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 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:<uuid>" 子会话(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)
}