- 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)
230 lines
5.7 KiB
Go
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:]
|
|
}
|