// 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:] }