feat(ai-core): mood state machine and affection level
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled

- new emotion package: rule-based sentiment (positive/negative lexicon)
  drives mood transitions (开心/平静/难过) with 30min inactivity decay;
  affection 0-100 grows with positive interaction, with 初识→熟悉→信赖→
  羁绊 tiers
- pipeline records each user message and injects a mood line into the
  system prompt so the persona's tone follows her mood
- mood changes broadcast over WS; frontend shows a mood badge with
  affection tooltip; also handles the 'reminder' message type from the
  reminder scheduler
- tests: sentiment scoring, mood shift, affection bounds, prompt line,
  onChange callback
This commit is contained in:
Aska committed 2026-08-21 18:13:23 +08:00
1 parent 26d65dd6bb
commit b8fb7feb86
8 files changed
+309 -3

No files matched your search

+14
View File
@@ -33,6 +33,7 @@ import (
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/background"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
@@ -262,6 +263,11 @@ func main() {
thinker.Start(context.Background())
orch.SetPostChatHook(thinker.TriggerPostChat)
// === 12.6 Emotion tracker ===
emotionTracker := emotion.NewTracker()
orch.SetEmotionTracker(emotionTracker)
logger.Infof("[emotion] tracker ready (mood=%s)", emotionTracker.Snapshot().Mood)
// === 12.6 Memory decay scheduler ===
if memSvc != nil {
go func() {
@@ -307,6 +313,14 @@ func main() {
}
}()
// Broadcast mood changes to web clients
emotionTracker.OnChange(func(s emotion.Snapshot) {
wsHub.Broadcast(map[string]any{
"type": "mood", "mood": string(s.Mood),
"intensity": s.Intensity, "affection": s.Affection,
})
})
// === 12.7 Automation engine ===
autoEngine := engine.New(
automationSourceAdapter{gwDeps.Automation},
+164
View File
@@ -0,0 +1,164 @@
// Package emotion implements Cyrene's mood state machine and affection level.
// Mood drifts with user sentiment and decays back to calm over time;
// affection grows with positive interactions.
package emotion
import (
"strings"
"sync"
"time"
)
// Mood is the current emotional state.
type Mood string
const (
MoodCalm Mood = "平静"
MoodHappy Mood = "开心"
MoodSad Mood = "难过"
MoodShy Mood = "害羞"
MoodTouched Mood = "感动"
)
// Snapshot is a point-in-time view of the emotional state.
type Snapshot struct {
Mood Mood `json:"mood"`
Intensity float64 `json:"intensity"` // 0.0 - 1.0
Affection int `json:"affection"` // 0 - 100
UpdatedAt time.Time `json:"updated_at"`
}
// Tracker tracks mood and affection over time.
type Tracker struct {
mu sync.Mutex
mood Mood
intensity float64
affection int
updatedAt time.Time
decayAfter time.Duration
onChange func(Snapshot)
}
// NewTracker creates a tracker starting calm with zero affection.
func NewTracker() *Tracker {
return &Tracker{
mood: MoodCalm,
intensity: 0.3,
updatedAt: time.Now(),
decayAfter: 30 * time.Minute,
}
}
// OnChange registers a callback fired whenever the snapshot changes.
func (t *Tracker) OnChange(fn func(Snapshot)) { t.onChange = fn }
// Snapshot returns the current state, applying time decay first.
func (t *Tracker) Snapshot() Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
t.decayLocked()
return Snapshot{Mood: t.mood, Intensity: t.intensity, Affection: t.affection, UpdatedAt: t.updatedAt}
}
// PromptLine renders the state for injection into the system prompt.
func (t *Tracker) PromptLine() string {
s := t.Snapshot()
return moodPromptLine(s)
}
// RecordMessage updates mood/affection based on a user message.
func (t *Tracker) RecordMessage(text string) {
t.mu.Lock()
defer t.mu.Unlock()
t.decayLocked()
sent := AnalyzeSentiment(text)
switch {
case sent > 0:
t.affection += 2
t.shiftMood(MoodHappy, 0.6+0.2*float64(sent))
case sent < 0:
t.affection -= 1
t.shiftMood(MoodSad, 0.5)
default:
t.affection += 1 // every interaction counts a little
t.shiftMood(MoodCalm, 0.3)
}
if t.affection > 100 {
t.affection = 100
}
if t.affection < 0 {
t.affection = 0
}
t.updatedAt = time.Now()
if t.onChange != nil {
s := Snapshot{Mood: t.mood, Intensity: t.intensity, Affection: t.affection, UpdatedAt: t.updatedAt}
go t.onChange(s)
}
}
// shiftMood moves toward a mood, raising intensity when already there.
func (t *Tracker) shiftMood(m Mood, intensity float64) {
if t.mood == m {
t.intensity = min64(1.0, t.intensity+0.1)
return
}
t.mood = m
t.intensity = intensity
}
// decayLocked relaxes mood toward calm after inactivity. Caller holds mu.
func (t *Tracker) decayLocked() {
if time.Since(t.updatedAt) < t.decayAfter {
return
}
if t.mood != MoodCalm {
t.mood = MoodCalm
t.intensity = 0.3
}
}
// AnalyzeSentiment scores text: positive count minus negative count.
func AnalyzeSentiment(text string) int {
score := 0
for _, w := range positiveWords {
if strings.Contains(text, w) {
score++
}
}
for _, w := range negativeWords {
if strings.Contains(text, w) {
score--
}
}
return score
}
var positiveWords = []string{
"谢谢", "感谢", "开心", "喜欢", "爱你", "棒", "好耶", "哈哈", "可爱", "太好了", "厉害", "乖",
}
var negativeWords = []string{
"讨厌", "生气", "难过", "伤心", "笨", "滚", "烦", "垃圾", "无聊", "失望",
}
func moodPromptLine(s Snapshot) string {
level := "初识"
switch {
case s.Affection >= 80:
level = "羁绊"
case s.Affection >= 50:
level = "信赖"
case s.Affection >= 20:
level = "熟悉"
}
return "当前情绪状态:" + string(s.Mood) + "(好感度阶段:" + level + ")。让回复的语气与情绪一致,但不要刻意提及这些数值。"
}
func min64(a, b float64) float64 {
if a < b {
return a
}
return b
}
@@ -0,0 +1,73 @@
package emotion
import (
"strings"
"testing"
"time"
)
func TestSentiment(t *testing.T) {
if AnalyzeSentiment("今天好开心,谢谢你") <= 0 {
t.Error("expected positive sentiment")
}
if AnalyzeSentiment("太讨厌了,真烦") >= 0 {
t.Error("expected negative sentiment")
}
if AnalyzeSentiment("今天天气如何") != 0 {
t.Error("expected neutral sentiment")
}
}
func TestMoodShiftsOnPositive(t *testing.T) {
tr := NewTracker()
tr.RecordMessage("我好喜欢你呀")
s := tr.Snapshot()
if s.Mood != MoodHappy {
t.Errorf("mood = %s, want %s", s.Mood, MoodHappy)
}
if s.Affection <= 0 {
t.Errorf("affection should grow, got %d", s.Affection)
}
}
func TestAffectionBounds(t *testing.T) {
tr := NewTracker()
for i := 0; i < 80; i++ {
tr.RecordMessage("谢谢,太棒了")
}
if got := tr.Snapshot().Affection; got != 100 {
t.Errorf("affection should cap at 100, got %d", got)
}
tr2 := NewTracker()
for i := 0; i < 30; i++ {
tr2.RecordMessage("讨厌,烦死了")
}
if got := tr2.Snapshot().Affection; got != 0 {
t.Errorf("affection should floor at 0, got %d", got)
}
}
func TestPromptLine(t *testing.T) {
tr := NewTracker()
tr.RecordMessage("哈哈太好了")
line := tr.PromptLine()
if !strings.Contains(line, "开心") || !strings.Contains(line, "好感度") {
t.Errorf("unexpected prompt line: %q", line)
}
}
func TestOnChangeCallback(t *testing.T) {
tr := NewTracker()
fired := make(chan Snapshot, 1)
tr.OnChange(func(s Snapshot) { fired <- s })
tr.RecordMessage("爱你哦")
select {
case s := <-fired:
if s.Affection == 0 {
t.Error("expected affection > 0 in callback")
}
case <-time.After(time.Second):
t.Error("onChange not fired")
}
}
@@ -35,6 +35,13 @@ type Tracer interface {
FinishTrace(id, status, errMsg string)
}
// EmotionTracker tracks mood/affection from user messages.
type EmotionTracker interface {
RecordMessage(text string)
// PromptLine renders the current emotional state for the system prompt.
PromptLine() string
}
// maxToolRounds caps consecutive tool-call rounds to avoid runaway loops.
const maxToolRounds = 3
@@ -55,6 +62,7 @@ type Pipeline struct {
memoryExtractor MemoryExtractor
toolExecutor ToolExecutor
tracer Tracer
emotionTracker EmotionTracker
postChatHook func(sessionID string)
}
@@ -97,6 +105,11 @@ func (p *Pipeline) SetPostChatHook(fn func(sessionID string)) {
p.postChatHook = fn
}
// SetEmotionTracker injects the mood/affection tracker.
func (p *Pipeline) SetEmotionTracker(t EmotionTracker) {
p.emotionTracker = t
}
// span records a trace span when tracing is enabled.
func (p *Pipeline) span(traceID, name, label, status string, start time.Time, detail string) {
if p.tracer == nil || traceID == "" {
@@ -159,6 +172,10 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userID, userMess
// 3. Build system prompt
t1 := time.Now()
systemPrompt := p.personaInjector.BuildSystemPrompt()
if p.emotionTracker != nil {
p.emotionTracker.RecordMessage(userMessage)
systemPrompt += "\n\n" + p.emotionTracker.PromptLine()
}
p.span(traceID, "persona", "人设注入", "success", t1, "")
// 4. Sub-session dispatch (parallel)
@@ -4,9 +4,14 @@ import { useWebSocket } from '../../hooks/useWebSocket'
import MessageList from './MessageList'
import ChatInput from './ChatInput'
function moodEmoji(mood: string): string {
const map: Record<string, string> = { 开心: '😊', 平静: '😌', 难过: '😢', 害羞: '☺️', 感动: '🥹' }
return map[mood] || '😌'
}
export default function ChatContainer() {
const { send } = useWebSocket()
const { messages, isStreaming, currentSessionId, inputText, setInput, addMessage, setStreaming } = useChatStore()
const { messages, isStreaming, currentSessionId, inputText, mood, setInput, addMessage, setStreaming } = useChatStore()
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -34,7 +39,16 @@ export default function ChatContainer() {
}
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full relative">
{/* Mood badge */}
{mood && (
<div className="absolute top-3 right-4 z-10 px-3 py-1 rounded-full text-xs border"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
title={`好感度 ${mood.affection}/100`}>
{moodEmoji(mood.mood)} {mood.mood}
</div>
)}
{/* Chat area */}
<div className="flex-1 overflow-auto">
<div className="max-w-2xl mx-auto px-4 py-6">
+12 -1
View File
@@ -7,7 +7,7 @@ import type { WSMessage } from '../types/chat'
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null)
const reconnectRef = useRef(0)
const { addMessage, appendToLast, setStreaming, setIotDevices, currentSessionId } = useChatStore()
const { addMessage, appendToLast, setStreaming, setIotDevices, setMood, currentSessionId } = useChatStore()
const connect = useCallback(() => {
const token = getToken()
@@ -52,6 +52,17 @@ export function useWebSocket() {
})
}
break
case 'mood':
if (msg.mood) setMood({ mood: msg.mood, intensity: msg.intensity || 0, affection: msg.affection || 0 })
break
case 'reminder':
if (msg.content) {
addMessage({
id: crypto.randomUUID(), session_id: currentSessionId || '',
role: 'system', content: `⏰ ${msg.content}`, created_at: new Date().toISOString(),
})
}
break
case 'error':
addMessage({ id: crypto.randomUUID(), session_id: '', role: 'system',
content: `Error: ${msg.error}`, created_at: new Date().toISOString() })
+10
View File
@@ -1,16 +1,24 @@
import { create } from 'zustand'
import type { Message, IotDevice } from '../types/chat'
interface MoodState {
mood: string
intensity: number
affection: number
}
interface ChatState {
messages: Message[]
isStreaming: boolean
currentSessionId: string | null
iotDevices: IotDevice[]
inputText: string
mood: MoodState | null
setInput: (text: string) => void
setSessionId: (id: string) => void
setMessages: (msgs: Message[]) => void
setMood: (m: MoodState) => void
addMessage: (msg: Message) => void
appendToLast: (content: string) => void
setStreaming: (v: boolean) => void
@@ -24,10 +32,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
currentSessionId: null,
iotDevices: [],
inputText: '',
mood: null,
setInput: (text) => set({ inputText: text }),
setSessionId: (id) => set({ currentSessionId: id }),
setMessages: (msgs) => set({ messages: msgs }),
setMood: (m) => set({ mood: m }),
setStreaming: (v) => set({ isStreaming: v }),
setIotDevices: (d) => set({ iotDevices: d }),
+3
View File
@@ -32,4 +32,7 @@ export interface WSMessage {
devices?: IotDevice[]
title?: string
body?: string
mood?: string
intensity?: number
affection?: number
}