diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index 301429a..badc618 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -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}, diff --git a/backend/services/ai-core/emotion/tracker.go b/backend/services/ai-core/emotion/tracker.go new file mode 100644 index 0000000..a5e5d88 --- /dev/null +++ b/backend/services/ai-core/emotion/tracker.go @@ -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 +} diff --git a/backend/services/ai-core/emotion/tracker_test.go b/backend/services/ai-core/emotion/tracker_test.go new file mode 100644 index 0000000..5815173 --- /dev/null +++ b/backend/services/ai-core/emotion/tracker_test.go @@ -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") + } +} diff --git a/backend/services/ai-core/orchestrator/orchestrator.go b/backend/services/ai-core/orchestrator/orchestrator.go index 869d0b1..3e9b617 100644 --- a/backend/services/ai-core/orchestrator/orchestrator.go +++ b/backend/services/ai-core/orchestrator/orchestrator.go @@ -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) diff --git a/frontend/web/src/components/chat/ChatContainer.tsx b/frontend/web/src/components/chat/ChatContainer.tsx index a99bae4..4728d60 100644 --- a/frontend/web/src/components/chat/ChatContainer.tsx +++ b/frontend/web/src/components/chat/ChatContainer.tsx @@ -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 = { 开心: '😊', 平静: '😌', 难过: '😢', 害羞: '☺️', 感动: '🥹' } + 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(null) useEffect(() => { @@ -34,7 +39,16 @@ export default function ChatContainer() { } return ( -
+
+ {/* Mood badge */} + {mood && ( +
+ {moodEmoji(mood.mood)} {mood.mood} +
+ )} + {/* Chat area */}
diff --git a/frontend/web/src/hooks/useWebSocket.ts b/frontend/web/src/hooks/useWebSocket.ts index d948de0..ca4fbfb 100644 --- a/frontend/web/src/hooks/useWebSocket.ts +++ b/frontend/web/src/hooks/useWebSocket.ts @@ -7,7 +7,7 @@ import type { WSMessage } from '../types/chat' export function useWebSocket() { const wsRef = useRef(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() }) diff --git a/frontend/web/src/store/chatStore.ts b/frontend/web/src/store/chatStore.ts index f32f80a..bd1207b 100644 --- a/frontend/web/src/store/chatStore.ts +++ b/frontend/web/src/store/chatStore.ts @@ -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((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 }), diff --git a/frontend/web/src/types/chat.ts b/frontend/web/src/types/chat.ts index 5453641..e6f3559 100644 --- a/frontend/web/src/types/chat.ts +++ b/frontend/web/src/types/chat.ts @@ -32,4 +32,7 @@ export interface WSMessage { devices?: IotDevice[] title?: string body?: string + mood?: string + intensity?: number + affection?: number }