Files
Cyrene_v2/backend/services/ai-core/background/thinker.go
T
Aska 8603839711
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
feat(ai-core): trace instrumentation, LLM thinker, decay scheduler
- orchestrator: Tracer interface with spans for intent analysis, persona,
  subsession dispatch, context build, LLM streaming and each tool call;
  traces finish when the stream completes — the admin Trace waterfall
  finally receives data
- background: Thinker generates real LLM thoughts (post-chat + silence
  triggers, recent history as context, empty/overlong outputs discarded);
  thoughts broadcast to web clients as 'thinking' WS messages; pipeline
  postChatHook triggers post-chat thinking after each round
- memory: ApplyDecayAll + ListUserIDs with a 6h decay/purge cron in main
  (was implemented but never scheduled)
2026-08-20 20:57:27 +08:00

230 lines
5.7 KiB
Go

// Package background provides the autonomous thinking engine for Cyrene.
// It generates thoughts after conversations and during idle periods.
package background
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
)
// LLMClient is the minimal LLM interface needed for thought generation.
type LLMClient interface {
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
}
// HistoryProvider returns recent conversation messages for a session.
type HistoryProvider func(sessionID string, limit int) []model.Message
// Thinker manages autonomous background thinking.
type Thinker struct {
mu sync.Mutex
enabled bool
thinkInterval time.Duration
silenceTimeout time.Duration
lastActivity time.Time
activeSessionID string
thoughts []Thought
stopCh chan struct{}
postChatCh chan string
llm LLMClient
history HistoryProvider
out chan Thought
}
// Thought represents an autonomous thought generated by the AI.
type Thought struct {
Content string `json:"content"`
SessionID string `json:"session_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// New creates a new background thinker.
func New(enabled bool, thinkInterval, silenceTimeout time.Duration) *Thinker {
return &Thinker{
enabled: enabled,
thinkInterval: thinkInterval,
silenceTimeout: silenceTimeout,
lastActivity: time.Now(),
thoughts: make([]Thought, 0),
stopCh: make(chan struct{}),
postChatCh: make(chan string, 10),
out: make(chan Thought, 10),
}
}
// SetLLM injects the LLM used to generate thoughts.
func (t *Thinker) SetLLM(c LLMClient) { t.llm = c }
// SetHistoryProvider injects a way to fetch recent conversation context.
func (t *Thinker) SetHistoryProvider(fn HistoryProvider) { t.history = fn }
// Start begins the background thinking loops.
func (t *Thinker) Start(ctx context.Context) {
if !t.enabled {
logger.Info("[thinker] background thinking disabled")
return
}
go t.silenceLoop(ctx)
go t.postChatLoop(ctx)
logger.Infof("[thinker] started (interval=%v, silence=%v)", t.thinkInterval, t.silenceTimeout)
}
// Stop shuts down the thinker.
func (t *Thinker) Stop() {
close(t.stopCh)
logger.Info("[thinker] stopped")
}
// TriggerPostChat schedules a post-chat think after a conversation.
func (t *Thinker) TriggerPostChat(sessionID string) {
t.mu.Lock()
t.activeSessionID = sessionID
t.lastActivity = time.Now()
t.mu.Unlock()
select {
case t.postChatCh <- sessionID:
default:
}
}
// UpdateActivity updates the last activity timestamp (for silence detection).
func (t *Thinker) UpdateActivity() {
t.mu.Lock()
t.lastActivity = time.Now()
t.mu.Unlock()
}
// ThoughtChan returns the channel that emits new thoughts.
func (t *Thinker) ThoughtChan() <-chan Thought {
return t.out
}
func (t *Thinker) silenceLoop(ctx context.Context) {
ticker := time.NewTicker(t.thinkInterval)
defer ticker.Stop()
for {
select {
case <-t.stopCh:
return
case <-ticker.C:
t.mu.Lock()
elapsed := time.Since(t.lastActivity)
t.mu.Unlock()
if elapsed >= t.silenceTimeout {
t.generateThought("silence", t.activeSessionID)
}
case <-ctx.Done():
return
}
}
}
func (t *Thinker) postChatLoop(ctx context.Context) {
for {
select {
case <-t.stopCh:
return
case sessionID := <-t.postChatCh:
// Delay post-chat thinking to avoid immediate noise
time.Sleep(5 * time.Second)
t.generateThought("post_chat", sessionID)
case <-ctx.Done():
return
}
}
}
func (t *Thinker) generateThought(trigger string, sessionID string) {
content := t.think(trigger, sessionID)
if content == "" {
return
}
thought := Thought{
Content: content,
SessionID: sessionID,
CreatedAt: time.Now(),
}
t.mu.Lock()
t.thoughts = append(t.thoughts, thought)
if len(t.thoughts) > 100 {
t.thoughts = t.thoughts[1:]
}
t.mu.Unlock()
select {
case t.out <- thought:
default:
}
logger.Infof("[thinker] %s thought generated for session %s", trigger, sessionID)
}
// think calls the LLM to produce a short inner thought. Returns "" when
// there is nothing worth saying (or no LLM is configured).
func (t *Thinker) think(trigger, sessionID string) string {
if t.llm == nil {
return ""
}
cue := "刚结束和主人的一段对话。"
if trigger == "silence" {
cue = "主人已经安静了一段时间。"
}
var sb strings.Builder
if t.history != nil && sessionID != "" {
for _, m := range t.history(sessionID, 6) {
role := "主人"
if m.Role == "assistant" {
role = "你"
}
fmt.Fprintf(&sb, "%s: %s\n", role, m.Content)
}
}
messages := []model.LLMMessage{
{Role: "system", Content: `你是昔涟的内心独白模块。根据最近的对话,生成一条简短的内心想法或想对主人说的话。
要求:一两句话,温柔自然的少女口吻;如果没有值得说的内容,只输出一个空字符串。
不要解释,不要加引号,直接输出想法本身。`},
{Role: "user", Content: fmt.Sprintf("情境:%s\n最近对话:\n%s", cue, sb.String())},
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := t.llm.Chat(ctx, messages)
if err != nil {
logger.Infof("[thinker] llm error: %v", err)
return ""
}
content := strings.TrimSpace(resp.Content)
// Discard empty or overly long outputs
if content == "" || len([]rune(content)) > 100 {
return ""
}
return content
}
// RecentThoughts returns the most recent thoughts.
func (t *Thinker) RecentThoughts(n int) []Thought {
t.mu.Lock()
defer t.mu.Unlock()
if n > len(t.thoughts) {
n = len(t.thoughts)
}
return t.thoughts[len(t.thoughts)-n:]
}