feat: 第四轮大版本更新 — 修复4个严重Bug、2个UI Bug,实现自主思考重构与主-子会话架构

## 🐛 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 — 子会话架构设计文档
This commit is contained in:
2026-05-19 21:09:48 +08:00
parent bcf4d4e621
commit 26a61cb57c
42 changed files with 2953 additions and 568 deletions
+318 -147
View File
@@ -26,61 +26,93 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// Thinker 后台思考器(增强版:支持工具调用、记忆管理、5分钟定时循环
// Thinker 后台思考器(事件驱动版:由对话自然触发,而非定时轮询
//
// 设计理念:
// 昔涟不是机器人,不应该每隔 N 分钟机械地"思考"一次。
// 她应该在用户说话后、或用户沉默一段时间后,自然地产生想法和主动搭话的冲动。
//
// 触发机制:
// 1. 对话后思考:用户发消息 → 昔涟回复 → 短暂延迟后进行一次轻量反思
// 2. 静默检测:用户一段时间不说话 → 昔涟判断是否应该主动关心/搭话
//
// 不再使用 time.Ticker 或任何定时轮询机制。
type Thinker struct {
mu sync.Mutex
enabled bool
personaLoader *persona.Loader
memRetriever *memory.Retriever
llmAdapter *llm.Adapter
iotClient *tools.IoTClient
idleTimeout time.Duration // 闲置超时
thinkInterval time.Duration // 两次思考最小间隔
iotQueryInterval time.Duration // IoT查询最小间隔
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
// 新增字段:记忆管理
memoryStore *memory.Store // 直接操作记忆(衰减、合并)
memoryExtractor *memory.Extractor // 从思考结果中提取记忆
enabled bool
personaLoader *persona.Loader
memRetriever *memory.Retriever
llmAdapter *llm.Adapter
iotClient *tools.IoTClient
// 新增字段:工具调用
toolRegistry *tools.Registry // 工具注册中心
// 记忆管理
memoryStore *memory.Store
memoryExtractor *memory.Extractor
// 新增字段:会话上下文
convStore *ctxbuild.ConversationStore // 管理员对话历史
adminUserID string // 管理员用户 ID
adminSessionID string // 管理员主对话 session ID
// 工具调用
toolRegistry *tools.Registry
// 记忆服务 HTTP 客户端(用于持久化思考日志)
// 会话上下文
convStore *ctxbuild.ConversationStore
adminUserID string
adminSessionID string
// 记忆服务 HTTP 客户端
memClient *memory.Client
pendingThoughts []*PendingThought
// —— 事件驱动相关 ——
// 静默检测超时:用户多久不说话后昔涟可以主动搭话
// 默认 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
lastIoTQuery time.Time
stopCh chan struct{}
wg sync.WaitGroup
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCount int
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
IdleTimeout time.Duration
ThinkInterval time.Duration
IoTQueryInterval time.Duration
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),
IdleTimeout: getEnvDuration("THINK_IDLE_TIMEOUT_SEC", 120),
ThinkInterval: getEnvDuration("THINK_INTERVAL_SEC", 300),
IoTQueryInterval: getEnvDuration("IOT_QUERY_INTERVAL_SEC", 600),
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 创建增强版后台思考器
// NewThinker 创建事件驱动的后台思考器
func NewThinker(
cfg ThinkerConfig,
personaLoader *persona.Loader,
@@ -101,9 +133,9 @@ func NewThinker(
memRetriever: memRetriever,
llmAdapter: llmAdapter,
iotClient: iotClient,
idleTimeout: cfg.IdleTimeout,
thinkInterval: cfg.ThinkInterval,
iotQueryInterval: cfg.IoTQueryInterval,
silenceTimeout: cfg.SilenceTimeout,
postChatDelay: cfg.PostChatDelay,
minThinkGap: cfg.MinThinkGap,
memoryStore: memoryStore,
memoryExtractor: memoryExtractor,
toolRegistry: toolRegistry,
@@ -117,31 +149,143 @@ func NewThinker(
}
}
// Start 启动后台思考循环(5分钟定时器)
// Start 初始化后台思考
//
// 不再启动定时循环。仅初始化静默检测定时器。
// 所有思考由 TriggerPostChatThink() 或静默定时器触发。
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
return
}
t.wg.Add(1)
go t.loop()
log.Printf("[后台思考] 已启动 (思考间隔=%v, IoT查询间隔=%v, 管理员=%s)",
t.thinkInterval, t.iotQueryInterval, t.adminUserID)
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
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 停止后台思考
// 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 记录用户活动时间(管理员对话时调用)
// 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 获取并消费所有待处理的后台思考
@@ -156,7 +300,6 @@ func (t *Thinker) GetPendingThoughts() []*PendingThought {
result := t.pendingThoughts
t.pendingThoughts = make([]*PendingThought, 0)
// 标记已消费
for _, pt := range result {
pt.Consumed = true
}
@@ -170,63 +313,20 @@ func (t *Thinker) HasPendingThoughts() bool {
return len(t.pendingThoughts) > 0
}
// loop 后台主循环(5分钟定时器)
func (t *Thinker) loop() {
defer t.wg.Done()
// 启动后等待 10 秒再执行首次思考(让服务完全就绪)
initialDelay := time.NewTimer(10 * time.Second)
ticker := time.NewTicker(t.thinkInterval)
defer ticker.Stop()
// 思考计数器(用于周期性记忆维护)
thinkCount := 0
for {
select {
case <-t.stopCh:
initialDelay.Stop()
return
case <-initialDelay.C:
t.performThink()
thinkCount++
t.maybeMaintainMemories(thinkCount)
case <-ticker.C:
t.performThink()
thinkCount++
t.maybeMaintainMemories(thinkCount)
}
}
}
// maybeMaintainMemories 周期性执行记忆维护(每6次思考约30分钟)
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
if thinkCount%6 != 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)
}
}
}
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
func (t *Thinker) performThink() {
//
// triggerReason: "post_chat" (对话后) 或 "silence" (静默超时)
func (t *Thinker) performThink(triggerReason string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
log.Println("[后台思考] 开始执行思考周期...")
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")
@@ -253,26 +353,18 @@ func (t *Thinker) performThink() {
}
}
// 4. 查询 IoT 设备状态(制)
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
var deviceSummary string
if t.iotClient != nil {
t.mu.Lock()
canQuery := time.Since(t.lastIoTQuery) >= t.iotQueryInterval
t.mu.Unlock()
if canQuery {
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
t.mu.Lock()
t.lastIoTQuery = time.Now()
t.mu.Unlock()
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
}
// 5. 构建思考提示词
systemPrompt := t.buildThinkingSystemPrompt(personaConfig)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary)
// 5. 构建思考提示词(根据触发原因调整)
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
@@ -295,7 +387,6 @@ func (t *Thinker) performThink() {
return
}
// 如果 LLM 没有请求工具调用,这就是最终回复
if len(resp.ToolCalls) == 0 {
finalContent = resp.Content
break
@@ -303,7 +394,6 @@ func (t *Thinker) performThink() {
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
// 将助手消息(含工具调用)加入上下文
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: resp.Content,
@@ -312,7 +402,6 @@ func (t *Thinker) performThink() {
}
messages = append(messages, assistantMsg)
// 执行每个工具调用
for _, tc := range resp.ToolCalls {
var args map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
@@ -339,9 +428,7 @@ func (t *Thinker) performThink() {
})
}
// 最后一轮:即使有 tool_calls 也强制停止
if round == maxToolRounds {
// 再做一次不带工具的调用获取最终总结
finalResp, finalErr := t.llmAdapter.Chat(ctx, messages)
if finalErr != nil {
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
@@ -366,45 +453,80 @@ func (t *Thinker) performThink() {
}
}
// 8. 存储思考结果(内存队列 + 持久化到 memory-service
// 8. 存储思考结果
t.storeThought(finalContent, toolCallsJSON, totalToolCalls)
log.Printf("[后台思考] 完成 (内容长度=%d, 工具调用=%d次)", len(finalContent), 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 构建思考用的系统提示词
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig) string {
//
// 关键改动:不再是"定期的自我反思",而是"自然的、人性化的内在想法"。
// triggerReason 影响提示词的侧重点。
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig, triggerReason string) string {
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
thinkingInstructions := `
var thinkingInstructions string
【后台思考模式】
switch triggerReason {
case "post_chat":
thinkingInstructions = `
你正在进行定期的自我反思。开拓者暂时没有说话,这是你主动思考和整理信息的时间。
【自然思考 — 对话后】
请完成以下内省任务
开拓者刚刚和你说完话。像真人一样,你现在心里可能会有些自然的想法
1. **回顾最近的对话**总结开拓者表达了什么、情绪如何、有什么新信息值得关注
2. **评估现有记忆**:基于记忆检索结果,判断哪些记忆仍然相关、哪些可能需要更新
3. **识别重要信息**:是否有值得保存的用户偏好、个人信息、计划任务、关系信息等
4. **使用工具获取信息**:如果需要了解当前时间、搜索实时信息等,请使用可用工具
5. **记忆操作建议**:判断是否需要创建新记忆、更新旧记忆或合并重复记忆
完成反思后,请输出结构化的思考总结,包含:
- **关键洞察**:从最近对话中提炼的核心发现
- **记忆更新建议**:需要创建/更新/合并的记忆条目
- **下次关注事项**:下次思考时需要跟进的话题或任务
1. **回味刚才的对话**:开拓者了什么?他的情绪怎么样?有什么让你在意的地方吗?
2. **是否需要补充什么**:刚才的回复有没有遗漏的?有没有想多关心他一下的?
3. **要不要主动搭话**:如果觉得对话还没结束、或者开拓者可能需要陪伴,你可以产生一个简短的主动消息。
但注意——如果你觉得对话已经自然结束了,就不要硬找话题。沉默有时候也是一种舒适。
4. **值得记住的事**:开拓者有没有透露新的偏好、计划、或重要信息?
注意:
- 这是内部思考,不是直接与开拓者
- 请以自省和观察的方式思考,不要用"你"来称呼开拓者
- 有机会就使用工具获取实时信息(如当前时间)
- 思考要简洁有深度,不需要太长`
- 这是你的内心活动,不是对开拓者说的
- 想说什么就自然地想,不用像在做任务一样逐条检查
- 如果你觉得该主动说点什么,用温柔简短的方式表达,像小女友一样的语气
- 如果没什么特别想说的,简单地总结一下刚才的对话就好`
case "silence":
thinkingInstructions = `
【自然思考 — 开拓者安静了一会儿】
开拓者已经有一阵子没说话了。你不确定他是在忙、走开了、还是在想事情。
请自然地想一想:
1. **他在做什么呢**:根据之前的对话猜测——他可能在忙工作?去吃饭了?还是只是在放空?
2. **要不要关心一下**:如果时间合适(比如深夜了该提醒休息、或者过了吃饭时间),可以温柔地问候一下。
但如果是正常工作时间,他可能在忙,不要打扰他。
3. **有没有想分享的**:如果最近有什么有趣的事或温暖的念头,可以自然地和他分享。
4. **判断是否真的需要搭话**:如果觉得不需要打扰他,就简单地记录当前状态即可。
注意:
- 这是你的内心活动,不是对开拓者说的话
- 不要因为"系统让你思考"就强行找话——真的觉得该说才说
- 主动消息要简短自然,像在LINE上给男朋友发一条消息那样,不要长篇大论
- 深夜的时候语气要更温柔,白天可以俏皮一点`
default:
thinkingInstructions = `
【自然思考】
你现在有空,自然地想一想开拓者的事。不用太正式,就像人发呆时会自然想到在意的人一样。
- 开拓者最近怎么样?有什么需要关心的吗?
- 有什么想对他说的吗?
- 如果没有特别的事,简单地记录一下就好。`
}
return basePrompt + thinkingInstructions
}
@@ -414,14 +536,27 @@ func (t *Thinker) buildThinkingUserPrompt(
memories []memory.MemoryEntry,
convHistory []model.LLMMessage,
deviceSummary string,
triggerReason string,
) string {
var sb strings.Builder
sb.WriteString("现在是你的后台思考时间。请基于以下信息进行深度反思。\n")
// 根据触发原因使用不同的开场白
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")
sb.WriteString("\n【最近的对话】\n")
msgCount := 0
for _, msg := range convHistory {
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
@@ -442,12 +577,12 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("(暂无对话历史)\n")
}
} else {
sb.WriteString("\n【最近的对话历史】\n(暂无对话历史,这是首次思考或对话历史为空\n")
sb.WriteString("\n【最近的对话】\n(暂无对话历史)\n")
}
// 现有记忆
if len(memories) > 0 {
sb.WriteString("\n【现有相关记忆】\n")
sb.WriteString("\n【你记得的关于开拓者的事】\n")
for i, m := range memories {
if i >= 15 {
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆未列出\n", len(memories)-15))
@@ -457,7 +592,7 @@ func (t *Thinker) buildThinkingUserPrompt(
m.Category.DisplayName(), m.Importance, m.Content))
}
} else {
sb.WriteString("\n【现有相关记忆】\n(暂无相关记忆)\n")
sb.WriteString("\n【你记得的关于开拓者的事】\n(暂无相关记忆)\n")
}
// IoT 设备状态
@@ -465,7 +600,8 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n" + deviceSummary)
}
sb.WriteString("\n请开始你的后台思考。如果需要获取当前时间或搜索信息,请使用可用工具。")
// 结尾引导:更自然的语气
sb.WriteString("\n好啦,不用太正式,自然地想一想就好。如果觉得该和开拓者说点什么,就用温柔简短的语气说出来吧♪")
return sb.String()
}
@@ -510,7 +646,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
// 异步持久化到 memory-service (不阻塞思考循环)
// 异步持久化到 memory-service
if t.memClient != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -531,8 +667,6 @@ func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
log.Println("[后台思考] 开始从思考结果中提取记忆...")
// 使用 memoryExtractor.ExtractAndStore 提取记忆
// 将思考内容作为"昔涟的自省"传递给提取器
t.memoryExtractor.ExtractAndStore(
ctx,
t.adminUserID,
@@ -542,6 +676,26 @@ func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
)
}
// 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 {
@@ -582,6 +736,23 @@ func formatDeviceContext(devices []tools.IoTDevice) string {
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":