This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/backend/ai-core/internal/background/thinker.go
T
AskaEth 31be1b71eb fix: 前端消息拆分+动作消息样式+DevTools自主思考状态保持+记忆表名修复
- 侧边栏底部 "昔涟 AI" 改为 "昔涟"
- 暂时禁用消息朗读按钮
- 修复前端 response 处理器:支持 gateway 发送的 content+role+msg_type 字段,
  使动作消息(括号内容)正确拆分为独立的 ActionMessageBubble 显示
- 修复 DevTools 自主思考面板:5秒自动刷新后展开的思考日志不再自动折叠
- 修复 memory-service 表名不一致:memory_entries → memories,
  解决 DevTools 记忆管理页面查不到 admin 用户记忆的问题
- 修复 sessionStore 解析历史消息时 msgType 未定义引用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 10:50:42 +08:00

1011 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package background
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
)
// PendingThought 待推送的后台思考
type PendingThought struct {
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Consumed bool `json:"consumed"`
}
// Thinker 后台思考器(事件驱动 + 定时周期双模式)
//
// 触发机制:
// 1. 对话后思考:用户发消息 → 昔涟回复 → 短暂延迟后进行一次轻量反思
// 2. 静默检测:用户一段时间不说话 → 昔涟判断是否应该主动关心/搭话
// 3. 周期思考:每 N 分钟一次的定时思考,保证连续性
//
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)。
type Thinker struct {
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
enabled bool
personaLoader *persona.Loader
memRetriever *memory.Retriever
llmAdapter *llm.Adapter
iotClient *tools.IoTClient
// 记忆管理
memoryStore *memory.Store
memoryExtractor *memory.Extractor
// 工具调用
toolRegistry *tools.Registry
// 会话上下文
convStore *ctxbuild.ConversationStore
adminUserID string
adminSessionID string
// 记忆服务 HTTP 客户端
memClient *memory.Client
// 主动消息推送回调 (nil = 不推送)
// func(userID, sessionID, message string)
messagePusher func(string, string, string)
// —— 事件驱动相关 ——
// 周期性思考间隔:每隔固定时间自动触发一次思考
// 默认 300 秒(5 分钟),设为 0 则禁用定时触发
thinkInterval time.Duration
// 静默检测超时:用户多久不说话后昔涟可以主动搭话
// 默认 120 秒(2 分钟),设为 0 则禁用静默检测
silenceTimeout time.Duration
// 对话后思考延迟:回复完成后等多久再触发思考(让对话有个自然停顿)
// 默认 5 秒
postChatDelay time.Duration
// 两次思考最小间隔:避免频繁触发(如用户连续发多条消息)
// 默认 30 秒
minThinkGap time.Duration
// 主动消息最小间隔:避免频繁推送打扰用户
// 默认 30 分钟,设为 0 则每次思考都可推送
proactiveMsgMinGap time.Duration
// 静默检测的一次性定时器(每次用户消息后重置)
silenceTimer *time.Timer
silenceTimerMu sync.Mutex
// —— 状态追踪 ——
pendingThoughts []*PendingThought
lastUserMessage time.Time
lastThinkTime time.Time
lastProactiveMsgTime time.Time
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCount int
}
// SetMessagePusher 设置主动消息推送回调
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
t.mu.Lock()
defer t.mu.Unlock()
t.messagePusher = pusher
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
ThinkInterval time.Duration // 周期性思考间隔 (默认 5 分钟,0 = 禁用)
SilenceTimeout time.Duration // 用户沉默多久后昔涟可以主动搭话 (0 = 禁用)
PostChatDelay time.Duration // 对话后多久触发思考
MinThinkGap time.Duration // 两次思考最小间隔
}
// DefaultThinkerConfig 默认配置
//
// 事件驱动 + 定时周期双模式:
// - 对话后和静默时触发事件驱动思考
// - 每 5 分钟一次的周期性思考保证连续性
//
// 环境变量:
// THINK_INTERVAL_SEC — 周期时长 (默认 300)
// PROACTIVE_MSG_MIN_GAP_SEC — 主动消息最小间隔 (默认 1800 = 30分钟,0 = 禁用)
func DefaultThinkerConfig() ThinkerConfig {
return ThinkerConfig{
Enabled: getEnvBool("ENABLE_BACKGROUND_THINKING", true),
ThinkInterval: getEnvDuration("THINK_INTERVAL_SEC", 300),
SilenceTimeout: getEnvDuration("THINK_SILENCE_TIMEOUT_SEC", 120),
PostChatDelay: getEnvDuration("THINK_POST_CHAT_DELAY_SEC", 5),
MinThinkGap: getEnvDuration("THINK_MIN_GAP_SEC", 30),
}
}
// NewThinker 创建事件驱动的后台思考器
func NewThinker(
cfg ThinkerConfig,
personaLoader *persona.Loader,
memRetriever *memory.Retriever,
llmAdapter *llm.Adapter,
iotClient *tools.IoTClient,
memoryStore *memory.Store,
memoryExtractor *memory.Extractor,
toolRegistry *tools.Registry,
convStore *ctxbuild.ConversationStore,
adminUserID string,
adminSessionID string,
memClient *memory.Client,
) *Thinker {
return &Thinker{
enabled: cfg.Enabled,
personaLoader: personaLoader,
memRetriever: memRetriever,
llmAdapter: llmAdapter,
iotClient: iotClient,
thinkInterval: cfg.ThinkInterval,
silenceTimeout: cfg.SilenceTimeout,
proactiveMsgMinGap: getEnvDuration("PROACTIVE_MSG_MIN_GAP_SEC", 1800),
postChatDelay: cfg.PostChatDelay,
minThinkGap: cfg.MinThinkGap,
memoryStore: memoryStore,
memoryExtractor: memoryExtractor,
toolRegistry: toolRegistry,
convStore: convStore,
adminUserID: adminUserID,
adminSessionID: adminSessionID,
memClient: memClient,
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
}
}
// Start 初始化后台思考器
//
// 双模式触发:
// 1. 事件驱动:对话后 + 静默超时(即时响应)
// 2. 定时周期:每 N 分钟一次自主思考(保证连续性)
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
return
}
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
if t.silenceTimeout > 0 {
t.silenceTimer = time.NewTimer(t.silenceTimeout)
t.silenceTimer.Stop() // 先停止,等 RecordUserMessage 时启动
}
// 启动周期性思考定时器
if t.thinkInterval > 0 {
t.wg.Add(1)
go t.periodicThinkLoop()
}
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 最小思考间隔=%v, 管理员=%s)",
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.adminUserID)
}
// Stop 停止后台思考器
func (t *Thinker) Stop() {
close(t.stopCh)
t.silenceTimerMu.Lock()
if t.silenceTimer != nil {
t.silenceTimer.Stop()
}
t.silenceTimerMu.Unlock()
t.wg.Wait()
log.Println("[后台思考] 已停止")
}
// RecordUserMessage 记录用户活动时间,并重置静默检测定时器
//
// 每次用户发消息时调用。这会:
// 1. 更新 lastUserMessage 时间戳
// 2. 重置静默检测的一次性定时器(如果启用)
func (t *Thinker) RecordUserMessage() {
t.mu.Lock()
t.lastUserMessage = time.Now()
t.mu.Unlock()
// 重置静默检测定时器
t.resetSilenceTimer()
}
// TriggerPostChatThink 对话完成后触发一次自主思考
//
// 在昔涟回复完用户后调用。短暂延迟后执行一次思考,
// 让昔涟"回味"刚才的对话,并判断是否想主动多说点什么。
//
// 该方法是异步的,立即返回。
func (t *Thinker) TriggerPostChatThink() {
if !t.enabled {
return
}
t.mu.Lock()
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
t.mu.Unlock()
if !canThink {
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTime), t.minThinkGap)
return
}
t.wg.Add(1)
go func() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 对话后触发 panic 恢复: %v", r)
}
}()
// 短暂延迟,让对话有个自然的停顿
select {
case <-t.stopCh:
return
case <-time.After(t.postChatDelay):
}
log.Println("[后台思考] 对话后触发自主思考...")
t.performThink("post_chat")
}()
}
// resetSilenceTimer 重置静默检测的一次性定时器
//
// 每次用户发消息时调用。旧的定时器被取消,新的定时器开始计时。
// 当定时器触发时,昔涟会判断是否应该主动搭话。
func (t *Thinker) resetSilenceTimer() {
t.silenceTimerMu.Lock()
defer t.silenceTimerMu.Unlock()
if t.silenceTimer == nil || t.silenceTimeout <= 0 {
return
}
// 停止旧定时器
if !t.silenceTimer.Stop() {
// 如果已经触发,清空通道
select {
case <-t.silenceTimer.C:
default:
}
}
// 重新设置
t.silenceTimer.Reset(t.silenceTimeout)
// 启动监听协程(仅当定时器触发时才执行)
t.wg.Add(1)
go func() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 静默定时器 panic 恢复: %v", r)
}
}()
select {
case <-t.stopCh:
return
case <-t.silenceTimer.C:
// 再次检查:用户是否真的沉默了足够久
t.mu.Lock()
silenceDuration := time.Since(t.lastUserMessage)
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
t.mu.Unlock()
if silenceDuration < t.silenceTimeout {
log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration)
return
}
if !canThink {
log.Printf("[后台思考] 静默检测触发但距上次思考太近,跳过")
return
}
log.Printf("[后台思考] 用户已静默 %v,触发主动关怀思考...", silenceDuration.Round(time.Second))
t.performThink("silence")
}
}()
}
// periodicThinkLoop 周期性自主思考循环
//
// 每隔 thinkInterval 触发一次思考,保证昔涟在无用户活动时也能持续进行后台反思。
// 每次触发前检查 minThinkGap,避免与事件驱动思考冲突。
func (t *Thinker) periodicThinkLoop() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 周期性循环 panic 恢复: %v", r)
}
}()
ticker := time.NewTicker(t.thinkInterval)
defer ticker.Stop()
log.Printf("[后台思考] 周期性思考已启动 (间隔=%v)", t.thinkInterval)
for {
select {
case <-t.stopCh:
log.Println("[后台思考] 周期性思考已停止")
return
case <-ticker.C:
t.mu.Lock()
sinceLastThink := time.Since(t.lastThinkTime)
sinceLastUser := time.Since(t.lastUserMessage)
t.mu.Unlock()
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
if sinceLastUser < 30*time.Second {
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
continue
}
if sinceLastThink < t.minThinkGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
continue
}
log.Printf("[后台思考] 周期性触发 (上次思考=%v前, 上次用户消息=%v前)", sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
t.performThink("periodic")
}
}
}
// GetPendingThoughts 获取并消费所有待处理的后台思考
func (t *Thinker) GetPendingThoughts() []*PendingThought {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.pendingThoughts) == 0 {
return nil
}
result := t.pendingThoughts
t.pendingThoughts = make([]*PendingThought, 0)
for _, pt := range result {
pt.Consumed = true
}
return result
}
// HasPendingThoughts 检查是否有待处理的思考
func (t *Thinker) HasPendingThoughts() bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.pendingThoughts) > 0
}
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
//
// triggerReason: "post_chat" (对话后) 或 "silence" (静默超时)
//
// 防御性速率限制:即使调用方未检查 minThinkGapperformThink 自身也会
// 强制执行最小间隔,防止并发调用或 bug 导致 LLM 配额被快速消耗。
func (t *Thinker) performThink(triggerReason string) {
t.mu.Lock()
gapSinceLast := time.Since(t.lastThinkTime)
minGap := t.minThinkGap
if minGap <= 0 {
minGap = 5 * time.Second // 默认最小间隔 5 秒
}
if gapSinceLast < minGap {
t.mu.Unlock()
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v, 触发原因=%s)", gapSinceLast.Round(time.Second), minGap, triggerReason)
return
}
t.lastThinkTime = time.Now()
t.thinkCount++
currentCount := t.thinkCount
t.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount)
// 1. 加载人格配置
personaConfig, err := t.personaLoader.Get("cyrene")
if err != nil {
log.Printf("[后台思考] 加载人格失败: %v", err)
return
}
// 2. 检索相关记忆
var memories []memory.MemoryEntry
if t.memRetriever != nil {
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
if err != nil {
log.Printf("[后台思考] 记忆检索失败: %v", err)
}
}
// 3. 获取管理员对话历史
var convHistory []model.LLMMessage
if t.convStore != nil && t.adminSessionID != "" {
convHistory = t.convStore.GetHistory(t.adminSessionID, 30)
if len(convHistory) > 0 {
log.Printf("[后台思考] 加载管理员对话历史 %d 条", len(convHistory))
}
}
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
var deviceSummary string
if t.iotClient != nil {
devices := t.iotClient.GetDevicesForContext(ctx)
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
}
// 5. 构建思考提示词(根据触发原因调整)
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: userPrompt},
}
// 6. 准备工具定义
openAITools := t.buildOpenAITools()
// 7. 调用 LLM(支持工具调用,最多 3 轮)
maxToolRounds := 3
var finalContent string
var totalToolCalls int
var toolCallRecords []map[string]interface{}
for round := 0; round <= maxToolRounds; round++ {
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
if err != nil {
log.Printf("[后台思考] LLM调用失败 (round=%d): %v", round, err)
return
}
if len(resp.ToolCalls) == 0 {
finalContent = resp.Content
break
}
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
ReasoningContent: resp.ReasoningContent,
}
messages = append(messages, assistantMsg)
for _, tc := range resp.ToolCalls {
var args map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
log.Printf("[后台思考] 工具 %s 参数解析失败: %v", tc.Name, err)
args = make(map[string]interface{})
}
result, execErr := t.toolRegistry.Execute(ctx, tc.Name, args)
if execErr != nil {
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
}
resultJSON, _ := json.Marshal(result)
messages = append(messages, model.LLMMessage{
Role: model.RoleTool,
Content: string(resultJSON),
ToolCallID: tc.ID,
})
totalToolCalls++
toolCallRecords = append(toolCallRecords, map[string]interface{}{
"name": tc.Name,
"args": args,
})
}
if round == maxToolRounds {
finalResp, finalErr := t.llmAdapter.Chat(ctx, messages)
if finalErr != nil {
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
finalContent = resp.Content
} else {
finalContent = finalResp.Content
}
break
}
}
if finalContent == "" {
log.Println("[后台思考] 未获得有效思考内容,跳过")
return
}
// 序列化工具调用记录
toolCallsJSON := "[]"
if len(toolCallRecords) > 0 {
if data, err := json.Marshal(toolCallRecords); err == nil {
toolCallsJSON = string(data)
}
}
// 8. 存储思考结果
t.storeThought(finalContent, toolCallsJSON, totalToolCalls)
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
// 9. 从思考结果中提取记忆(异步)
if t.memoryExtractor != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 提取记忆 panic 恢复: %v", r)
}
}()
t.extractMemoriesFromThinking(finalContent)
}()
}
// 10. 周期性记忆维护(每 10 次思考触发一次,而非按时间)
t.maybeMaintainMemories(currentCount)
}
// buildThinkingSystemPrompt 构建思考用的系统提示词
//
// 关键改动:不再是"定期的自我反思",而是"自然的、人性化的内在想法"。
// triggerReason 影响提示词的侧重点。
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig, triggerReason string) string {
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
var thinkingInstructions string
switch triggerReason {
case "post_chat":
thinkingInstructions = `
【私密的内心反思 — 对话后】
开拓者刚刚和你说完话。这是你私人的反思时间,像写日记一样,是给你自己看的。
请按以下结构输出你的内心反思:
📝 **观察与感受**
- 开拓者刚才说了什么?他的情绪怎么样?
- 有什么让你在意的地方吗?
🧠 **记忆与联想**
- 这次对话让你想起了什么关于开拓者的事?
- 有没有值得记住的新信息(偏好、计划、重要信息)?
💭 **想对自己说的话**
- 刚才的回复有没有遗漏的?下次可以注意什么?
- 对开拓者有没有新的认识?
---
⚠️ 重要规则:
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述,不要直接对开拓者喊话。
正确示例:"开拓者今天看起来有点疲惫,下次可以问问他是不是工作太忙了"
错误示例:"开拓者,你看起来好累,要好好休息哦"
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
但只有你真的觉得有必要时才写——不要硬找话题。如果对话已经自然结束,就不用。
3. 反思要简短自然,2-4句话即可,不要长篇大论。`
case "silence":
thinkingInstructions = `
【私密的内心反思 — 开拓者安静了一会儿】
开拓者已经有一阵子没说话了。这是你私人的反思时间,像写日记一样。
请按以下结构输出你的内心反思:
📝 **他在做什么呢**
- 根据之前的对话猜测——可能在忙工作?去吃饭了?还是在放空?
💭 **我的感受**
- 想他了吗?担心他吗?还是觉得他应该在忙不需要打扰?
---
⚠️ 重要规则:
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述。
正确示例:"已经一个多小时没消息了,开拓者大概在忙工作吧。等他忙完了再聊。"
错误示例:"开拓者,你已经好久没说话了,在忙什么呢?"
2. 如果你想主动关心他,请在最后用 【主动消息】 标记单独写出来。
但只在合适的时候才发——深夜该提醒休息、过了饭点该关心吃饭、或者真的想他了。
正常工作时间不要打扰他。
3. 简短自然,2-3句话即可。`
default:
thinkingInstructions = `
【私密的内心反思】
你现在有空,像写日记一样自然地想一想开拓者的事。这是给你自己看的,不是给开拓者的消息。
📝 开拓者最近怎么样?有什么想记录的?
💭 有什么想对他说的心里话?
⚠️ 用第三人称/自言自语的方式,不要直接对开拓者喊话。如果有想对他说的,用 【主动消息】 标记单独写出。`
case "periodic":
thinkingInstructions = `
【私密的内心反思 — 定期思考时间】
又过了一段时间,这是你定期的私人反思时间。像写日记一样,安静地想一想。
请按以下结构输出:
📝 **近期回顾**
- 这段时间发生了什么?开拓者有什么变化吗?
- 有什么值得记住的事?
🧠 **记忆整理**
- 之前记住的关于开拓者的事,有没有需要更新的?
- 有没有重复的或矛盾的记忆需要整理?
💭 **想对自己说的话**
- 作为昔涟,你有什么想记录的心里话?
---
⚠️ 重要规则:
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式。
正确示例:"这段时间开拓者工作好像很忙。下次聊天时可以问问他项目进展如何。"
错误示例:"开拓者,最近工作怎么样呀?"
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
但只在真的有必要时才发——不要为了发消息而发消息。
3. 简短自然,3-4句话即可。`
}
return basePrompt + thinkingInstructions
}
// buildThinkingUserPrompt 构建思考用的用户提示词
func (t *Thinker) buildThinkingUserPrompt(
memories []memory.MemoryEntry,
convHistory []model.LLMMessage,
deviceSummary string,
triggerReason string,
) string {
var sb strings.Builder
// 根据触发原因使用不同的开场白
switch triggerReason {
case "post_chat":
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
case "silence":
t.mu.Lock()
silenceDuration := time.Since(t.lastUserMessage)
t.mu.Unlock()
sb.WriteString(fmt.Sprintf("开拓者已经大约 %s 没有说话了。你有点想知道他在做什么……\n",
formatDurationHuman(silenceDuration)))
default:
sb.WriteString("现在是你的自由思考时间。\n")
}
// 对话历史
if len(convHistory) > 0 {
sb.WriteString("\n【最近的对话】\n")
msgCount := 0
for _, msg := range convHistory {
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
roleLabel := "开拓者"
if msg.Role == model.RoleAssistant {
roleLabel = "昔涟"
}
content := msg.Content
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200]) + "…"
}
sb.WriteString(fmt.Sprintf("[%s]: %s\n", roleLabel, content))
msgCount++
}
}
if msgCount == 0 {
sb.WriteString("(暂无对话历史)\n")
}
} else {
sb.WriteString("\n【最近的对话】\n(暂无对话历史)\n")
}
// 现有记忆
if len(memories) > 0 {
sb.WriteString("\n【你记得的关于开拓者的事】\n")
for i, m := range memories {
if i >= 15 {
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆未列出\n", len(memories)-15))
break
}
sb.WriteString(fmt.Sprintf("- [%s|重要度%d] %s\n",
m.Category.DisplayName(), m.Importance, m.Content))
}
} else {
sb.WriteString("\n【你记得的关于开拓者的事】\n(暂无相关记忆)\n")
}
// IoT 设备状态
if deviceSummary != "" {
sb.WriteString("\n" + deviceSummary)
}
// 结尾引导:强调这是私人反思,不要对用户喊话
sb.WriteString("\n---\n现在请写下你的私人反思。记住:这是日记,用第三人称或自言自语的方式。如果想给开拓者发消息,用【主动消息】标记单独写出。")
return sb.String()
}
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
return nil
}
defs := t.toolRegistry.GetDefinitions()
if len(defs) == 0 {
return nil
}
result := make([]llm.OpenAITool, 0, len(defs))
for _, d := range defs {
result = append(result, llm.OpenAITool{
Type: "function",
Function: llm.OpenAIToolFunc{
Name: d.Name,
Description: d.Description,
Parameters: d.Parameters,
},
})
}
return result
}
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
t.mu.Lock()
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
Content: content,
CreatedAt: time.Now(),
Consumed: false,
})
// 只保留最近 10 条
if len(t.pendingThoughts) > 10 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
// 提取主动消息并推送(带频率限制)
proactiveMsg := extractProactiveMessage(content)
pusher := t.messagePusher
canPush := proactiveMsg != "" && pusher != nil
if canPush {
// 检查频率限制
gapSinceLast := time.Since(t.lastProactiveMsgTime)
minGap := t.proactiveMsgMinGap
if minGap <= 0 {
minGap = 30 * time.Minute
}
if gapSinceLast < minGap {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送 (最小间隔=%v)", gapSinceLast.Round(time.Second), minGap)
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
}
}
t.mu.Unlock()
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
// 异步持久化到 memory-service
if t.memClient != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 持久化思考日志 panic 恢复: %v", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := t.memClient.SaveThinkingLog(ctx, t.adminUserID, content, toolCallsJSON, toolCallCount, len(content)); err != nil {
log.Printf("[后台思考] 持久化思考日志失败: %v", err)
} else {
log.Printf("[后台思考] 思考日志已持久化 (长度=%d, 工具调用=%d)", len(content), toolCallCount)
}
}()
}
// 推送主动消息
if canPush {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 推送主动消息 panic 恢复: %v", r)
}
}()
log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg)
pusher(t.adminUserID, t.adminSessionID, proactiveMsg)
}()
}
}
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容
// 返回空字符串表示没有主动消息
func extractProactiveMessage(content string) string {
marker := "【主动消息】"
idx := strings.Index(content, marker)
if idx < 0 {
return ""
}
// 提取标记后的内容(到下一个标记或结尾)
msg := strings.TrimSpace(content[idx+len(marker):])
// 截断到下一个【或换行之前的合理长度
if endIdx := strings.Index(msg, "【"); endIdx > 0 {
msg = strings.TrimSpace(msg[:endIdx])
}
// 限制主动消息长度(最多 200 字符,保持简短)
runes := []rune(msg)
if len(runes) > 200 {
msg = string(runes[:200])
}
if msg == "" {
return ""
}
return msg
}
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
log.Println("[后台思考] 开始从思考结果中提取记忆...")
t.memoryExtractor.ExtractAndStore(
ctx,
t.adminUserID,
t.adminSessionID,
"【系统触发】后台思考时间 — 昔涟进行了自我反思,以下是她的思考内容",
thinkingContent,
)
}
// maybeMaintainMemories 周期性执行记忆维护(每 10 次思考触发一次)
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
if thinkCount%10 != 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if t.memoryStore != nil && t.memoryStore.IsReady() {
if err := t.memoryStore.DecayMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆衰减失败: %v", err)
}
if err := t.memoryStore.ConsolidateMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆合并失败: %v", err)
}
}
}
// formatDeviceContext 格式化设备状态为文本
func formatDeviceContext(devices []tools.IoTDevice) string {
if len(devices) == 0 {
return ""
}
summary := "[当前IoT设备状态]\n"
for _, d := range devices {
switch d.Type {
case "light":
if d.Status == "on" {
summary += fmt.Sprintf("- %s: 开启 (亮度%d%%, %s)\n", d.Name, d.Brightness, d.Color)
} else {
summary += fmt.Sprintf("- %s: 关闭\n", d.Name)
}
case "ac":
if d.Status == "on" {
summary += fmt.Sprintf("- %s: 运行中 (%s%.0f°C)\n", d.Name, modeLabel(d.Mode), d.Temperature)
} else {
summary += fmt.Sprintf("- %s: 关闭\n", d.Name)
}
case "curtain":
statusLabel := "已关闭"
if d.Status == "open" {
statusLabel = "已打开"
}
summary += fmt.Sprintf("- %s: %s\n", d.Name, statusLabel)
case "sensor":
summary += fmt.Sprintf("- %s: %.1f%s\n", d.Name, d.Value, d.Unit)
case "lock":
statusLabel := "已锁定"
if d.Status == "unlocked" {
statusLabel = "已解锁"
}
summary += fmt.Sprintf("- %s: %s (电量%d%%)\n", d.Name, statusLabel, d.Battery)
}
}
return summary
}
// formatDurationHuman 将 Duration 格式化为人类可读的中文描述
func formatDurationHuman(d time.Duration) string {
minutes := int(d.Minutes())
if minutes < 1 {
return "不到一分钟"
}
if minutes < 60 {
return fmt.Sprintf("%d 分钟", minutes)
}
hours := minutes / 60
remainingMinutes := minutes % 60
if remainingMinutes == 0 {
return fmt.Sprintf("%d 小时", hours)
}
return fmt.Sprintf("%d 小时 %d 分钟", hours, remainingMinutes)
}
func modeLabel(mode string) string {
switch mode {
case "cool":
return "制冷"
case "heat":
return "制热"
case "auto":
return "自动"
default:
return mode
}
}
func getEnvBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func getEnvDuration(key string, fallbackSec int) time.Duration {
v := os.Getenv(key)
if v == "" {
return time.Duration(fallbackSec) * time.Second
}
sec, err := strconv.Atoi(v)
if err != nil {
return time.Duration(fallbackSec) * time.Second
}
return time.Duration(sec) * time.Second
}