26a61cb57c
## 🐛 Bug 修复 - 修复前端对话无响应:消除 ChatContainer 中的双重 WebSocket 连接,优化 sendMessage 失败提示 - 修复 Memory-Service 数据库迁移失败:ai-core 和 memory-service 均添加 ALTER TABLE ADD COLUMN IF NOT EXISTS 模式演化 - 修复语音/STT 不可用:添加 MediaRecorder API 降级方案,修复 whisper-cli 输出文件名错误 - 修复仪表盘数据库按钮失效:补充按钮 ID 属性,重写 controlDB() 控制逻辑 ## 🎨 UI 修复 - 修正用户消息头像位置:从 flex-row-reverse 改为 justify-end - 移除空聊天列表的 emoji 占位图标 ## ✨ 新功能 - devtools 新增 STT 处理日志面板(环形缓冲区 + WebSocket 广播 + 可视化表格) - 新增 ADMIN_NICKNAME 环境变量,支持自定义管理员昵称 ## 🔧 改进 - 注册流程增加昵称必填字段(前后端同步) ## 🏗️ 架构重构 - 重构自主思考逻辑:从定时器轮询改为事件驱动(对话后触发 + 静默检测),优化提示词使其更自然人性化 - 实现主-子会话架构:新增 4 种子会话类型(general/memory/iot/knowledge),意图分析 → 并行分发 → 结果合成流程 ## 📄 新增文档 - docs/architecture/main-session-sub-session-design.md — 子会话架构设计文档
792 lines
23 KiB
Go
792 lines
23 KiB
Go
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 后台思考器(事件驱动版:由对话自然触发,而非定时轮询)
|
|
//
|
|
// 设计理念:
|
|
// 昔涟不是机器人,不应该每隔 N 分钟机械地"思考"一次。
|
|
// 她应该在用户说话后、或用户沉默一段时间后,自然地产生想法和主动搭话的冲动。
|
|
//
|
|
// 触发机制:
|
|
// 1. 对话后思考:用户发消息 → 昔涟回复 → 短暂延迟后进行一次轻量反思
|
|
// 2. 静默检测:用户一段时间不说话 → 昔涟判断是否应该主动关心/搭话
|
|
//
|
|
// 不再使用 time.Ticker 或任何定时轮询机制。
|
|
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
|
|
|
|
// —— 事件驱动相关 ——
|
|
|
|
// 静默检测超时:用户多久不说话后昔涟可以主动搭话
|
|
// 默认 120 秒(2 分钟),设为 0 则禁用静默检测
|
|
silenceTimeout time.Duration
|
|
|
|
// 对话后思考延迟:回复完成后等多久再触发思考(让对话有个自然停顿)
|
|
// 默认 5 秒
|
|
postChatDelay time.Duration
|
|
|
|
// 两次思考最小间隔:避免频繁触发(如用户连续发多条消息)
|
|
// 默认 30 秒
|
|
minThinkGap time.Duration
|
|
|
|
// 静默检测的一次性定时器(每次用户消息后重置)
|
|
silenceTimer *time.Timer
|
|
silenceTimerMu sync.Mutex
|
|
|
|
// —— 状态追踪 ——
|
|
|
|
pendingThoughts []*PendingThought
|
|
lastUserMessage time.Time
|
|
lastThinkTime time.Time
|
|
|
|
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
|
|
thinkCount int
|
|
}
|
|
|
|
// ThinkerConfig 后台思考配置
|
|
type ThinkerConfig struct {
|
|
Enabled bool
|
|
SilenceTimeout time.Duration // 用户沉默多久后昔涟可以主动搭话 (0 = 禁用)
|
|
PostChatDelay time.Duration // 对话后多久触发思考
|
|
MinThinkGap time.Duration // 两次思考最小间隔
|
|
}
|
|
|
|
// DefaultThinkerConfig 默认配置
|
|
//
|
|
// 不再使用定时间隔,所有触发均由用户活动驱动。
|
|
// 环境变量向后兼容:旧的 THINK_IDLE_TIMEOUT_SEC 可用于静默超时。
|
|
func DefaultThinkerConfig() ThinkerConfig {
|
|
return ThinkerConfig{
|
|
Enabled: getEnvBool("ENABLE_BACKGROUND_THINKING", true),
|
|
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,
|
|
silenceTimeout: cfg.SilenceTimeout,
|
|
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 初始化后台思考器
|
|
//
|
|
// 不再启动定时循环。仅初始化静默检测定时器。
|
|
// 所有思考由 TriggerPostChatThink() 或静默定时器触发。
|
|
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 时启动
|
|
}
|
|
|
|
log.Printf("[后台思考] 已就绪 — 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 最小思考间隔=%v, 管理员=%s)",
|
|
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()
|
|
|
|
// 短暂延迟,让对话有个自然的停顿
|
|
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()
|
|
|
|
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")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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" (静默超时)
|
|
func (t *Thinker) performThink(triggerReason string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
t.mu.Lock()
|
|
t.lastThinkTime = time.Now()
|
|
t.thinkCount++
|
|
currentCount := t.thinkCount
|
|
t.mu.Unlock()
|
|
|
|
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()
|
|
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 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. **要不要主动搭话**:如果觉得对话还没结束、或者开拓者可能需要陪伴,你可以产生一个简短的主动消息。
|
|
但注意——如果你觉得对话已经自然结束了,就不要硬找话题。沉默有时候也是一种舒适。
|
|
4. **值得记住的事**:开拓者有没有透露新的偏好、计划、或重要信息?
|
|
|
|
注意:
|
|
- 这是你的内心活动,不是对开拓者说的话
|
|
- 想说什么就自然地想,不用像在做任务一样逐条检查
|
|
- 如果你觉得该主动说点什么,用温柔简短的方式表达,像小女友一样的语气
|
|
- 如果没什么特别想说的,简单地总结一下刚才的对话就好`
|
|
|
|
case "silence":
|
|
thinkingInstructions = `
|
|
|
|
【自然思考 — 开拓者安静了一会儿】
|
|
|
|
开拓者已经有一阵子没说话了。你不确定他是在忙、走开了、还是在想事情。
|
|
|
|
请自然地想一想:
|
|
1. **他在做什么呢**:根据之前的对话猜测——他可能在忙工作?去吃饭了?还是只是在放空?
|
|
2. **要不要关心一下**:如果时间合适(比如深夜了该提醒休息、或者过了吃饭时间),可以温柔地问候一下。
|
|
但如果是正常工作时间,他可能在忙,不要打扰他。
|
|
3. **有没有想分享的**:如果最近有什么有趣的事或温暖的念头,可以自然地和他分享。
|
|
4. **判断是否真的需要搭话**:如果觉得不需要打扰他,就简单地记录当前状态即可。
|
|
|
|
注意:
|
|
- 这是你的内心活动,不是对开拓者说的话
|
|
- 不要因为"系统让你思考"就强行找话——真的觉得该说才说
|
|
- 主动消息要简短自然,像在LINE上给男朋友发一条消息那样,不要长篇大论
|
|
- 深夜的时候语气要更温柔,白天可以俏皮一点`
|
|
|
|
default:
|
|
thinkingInstructions = `
|
|
|
|
【自然思考】
|
|
|
|
你现在有空,自然地想一想开拓者的事。不用太正式,就像人发呆时会自然想到在意的人一样。
|
|
|
|
- 开拓者最近怎么样?有什么需要关心的吗?
|
|
- 有什么想对他说的吗?
|
|
- 如果没有特别的事,简单地记录一下就好。`
|
|
}
|
|
|
|
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好啦,不用太正式,自然地想一想就好。如果觉得该和开拓者说点什么,就用温柔简短的语气说出来吧♪")
|
|
|
|
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:]
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
|
|
|
// 异步持久化到 memory-service
|
|
if t.memClient != nil {
|
|
go func() {
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|