feat(ai-core): mood state machine and affection level
- 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:
1 parent
26d65dd6bb
commit
b8fb7feb86
8 files changed
+309
-3
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -32,4 +32,7 @@ export interface WSMessage {
|
||||
devices?: IotDevice[]
|
||||
title?: string
|
||||
body?: string
|
||||
mood?: string
|
||||
intensity?: number
|
||||
affection?: number
|
||||
}
|
||||
Reference in new issue
Block a user