6ef9e082a6
- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST) - Gateway: VoiceStreamManager代理WS流式STT到voice-service - Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码 - 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端) - 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并 - 文档: 完善gateway-api.md和voice-service.md语音API文档 - 工具: scripts/voice/ 语音转换脚本集 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1958 lines
62 KiB
Go
1958 lines
62 KiB
Go
package background
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/memory"
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona"
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
|
||
|
||
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||
)
|
||
|
||
// PendingThought 待推送的后台思考
|
||
type PendingThought struct {
|
||
Content string `json:"content"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
Consumed bool `json:"consumed"`
|
||
}
|
||
|
||
// PlatformChannel represents a platform channel to observe for background thinking.
|
||
type PlatformChannel struct {
|
||
Platform string // qq, telegram, etc.
|
||
ChannelType string // group, private
|
||
ChannelID string // group ID or user QQ number
|
||
}
|
||
|
||
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
|
||
// Format: "qq:group:123456,telegram:group:789012"
|
||
func ParsePlatformChannels(raw string) []PlatformChannel {
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
var channels []PlatformChannel
|
||
for _, part := range strings.Split(raw, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
fields := strings.SplitN(part, ":", 3)
|
||
if len(fields) != 3 {
|
||
continue
|
||
}
|
||
channels = append(channels, PlatformChannel{
|
||
Platform: strings.TrimSpace(fields[0]),
|
||
ChannelType: strings.TrimSpace(fields[1]),
|
||
ChannelID: strings.TrimSpace(fields[2]),
|
||
})
|
||
}
|
||
return channels
|
||
}
|
||
|
||
// 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
|
||
toolAdapter *llm.Adapter // 工具调用专用适配器
|
||
iotClient *tools.IoTClient
|
||
|
||
// 记忆管理
|
||
memoryStore *memory.Store
|
||
|
||
// 工具调用
|
||
toolRegistry *plgManager.ToolRegistry
|
||
|
||
// 会话上下文
|
||
convStore *ctxbuild.ConversationStore
|
||
adminUserID string
|
||
adminSessionID string
|
||
adminNickname string
|
||
activeSessionID string // 当前活跃的前端会话 ID(随用户消息更新)
|
||
|
||
// 记忆服务 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
|
||
|
||
// 离线时最小思考间隔:用户不在线时的周期思考间隔
|
||
// 默认 10 分钟
|
||
offlineThinkGap 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
|
||
|
||
// Phase 1 Step 4: 思考链 + 自主工具安全策略
|
||
chain *ThinkChain
|
||
autoToolPolicy *AutonomousToolPolicy
|
||
|
||
// Phase 2: 情感追踪
|
||
emotionTracker *persona.EmotionTracker
|
||
|
||
// Phase 2: 主动消息决策守卫
|
||
proactiveGuard *ProactiveGuard
|
||
|
||
// 动态调度: 按时间段自动调整思考间隔
|
||
scheduleLoader *ScheduleLoader
|
||
|
||
// Phase 2: 在线状态追踪
|
||
userOnline bool
|
||
lastOnlineChange time.Time
|
||
userSessionID string // 当前活跃的 session ID (用于重连)
|
||
|
||
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
|
||
timeLocation *time.Location
|
||
|
||
// 平台静默观察
|
||
platformChannels []PlatformChannel
|
||
platformThinkInterval time.Duration
|
||
}
|
||
|
||
// AutonomousToolPolicy 自主思考工具调用安全策略
|
||
type AutonomousToolPolicy struct {
|
||
// 允许在自主思考中使用的工具白名单
|
||
AllowedTools []string // iot_query, memory_search, web_search, calculator, datetime
|
||
// 每轮最大工具调用次数
|
||
MaxToolCallsPerRound int // 默认 5
|
||
// 高风险操作每小时最大次数 (如 iot_control)
|
||
MaxHighRiskPerHour int // 默认 10
|
||
// 高风险工具列表
|
||
HighRiskTools []string // iot_control
|
||
}
|
||
|
||
// DefaultAutonomousToolPolicy 默认安全策略
|
||
func DefaultAutonomousToolPolicy() *AutonomousToolPolicy {
|
||
return &AutonomousToolPolicy{
|
||
AllowedTools: []string{
|
||
"iot_query", "iot_control", "memory_search", "web_search",
|
||
"calculator", "datetime", "web_fetch",
|
||
"host_exec", "host_file", "host_system",
|
||
"vision_analyze", "knowledge_search", "knowledge_ingest",
|
||
},
|
||
MaxToolCallsPerRound: 5,
|
||
MaxHighRiskPerHour: 10,
|
||
HighRiskTools: []string{"iot_control", "host_exec"},
|
||
}
|
||
}
|
||
|
||
// SetMessagePusher 设置主动消息推送回调
|
||
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
|
||
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.scheduleLoader = loader
|
||
}
|
||
|
||
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.messagePusher = pusher
|
||
}
|
||
|
||
// SetEmotionTracker sets the emotion tracker.
|
||
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.emotionTracker = et
|
||
}
|
||
|
||
// UpdatePresence updates the user online status.
|
||
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
|
||
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
||
t.mu.Lock()
|
||
wasOffline := !t.userOnline
|
||
t.userOnline = online
|
||
t.lastOnlineChange = time.Now()
|
||
if sessionID != "" {
|
||
t.userSessionID = sessionID
|
||
t.activeSessionID = sessionID
|
||
}
|
||
t.mu.Unlock()
|
||
|
||
if online && wasOffline {
|
||
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
|
||
// Trigger a return-thinking cycle after a short delay
|
||
time.Sleep(2 * time.Second)
|
||
t.performThink("user_returned")
|
||
// Also update emotion tracker
|
||
if t.emotionTracker != nil {
|
||
t.emotionTracker.UpdateMood("user_returned")
|
||
}
|
||
} else if !online {
|
||
log.Printf("[后台思考] 用户离线")
|
||
}
|
||
}
|
||
|
||
// ThinkerConfig 后台思考配置
|
||
type ThinkerConfig struct {
|
||
Enabled bool
|
||
ThinkInterval time.Duration // 周期性思考间隔 (默认 5 分钟,0 = 禁用)
|
||
SilenceTimeout time.Duration // 用户沉默多久后昔涟可以主动搭话 (0 = 禁用)
|
||
PostChatDelay time.Duration // 对话后多久触发思考
|
||
MinThinkGap time.Duration // 两次思考最小间隔 (在线)
|
||
OfflineThinkGap time.Duration // 两次思考最小间隔 (离线,默认 10 分钟)
|
||
|
||
// 平台静默观察
|
||
PlatformSilentThinkInterval time.Duration // 平台记忆观察间隔 (默认 600s,0 = 禁用)
|
||
PlatformChannels []PlatformChannel
|
||
}
|
||
|
||
// 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),
|
||
OfflineThinkGap: getEnvDuration("THINK_OFFLINE_GAP_SEC", 600),
|
||
PlatformSilentThinkInterval: getEnvDuration("PLATFORM_THINK_INTERVAL_SEC", 600),
|
||
PlatformChannels: ParsePlatformChannels(os.Getenv("PLATFORM_CHANNELS")),
|
||
}
|
||
}
|
||
|
||
// NewThinker 创建事件驱动的后台思考器
|
||
func NewThinker(
|
||
cfg ThinkerConfig,
|
||
personaLoader *persona.Loader,
|
||
memRetriever *memory.Retriever,
|
||
llmAdapter *llm.Adapter,
|
||
toolAdapter *llm.Adapter,
|
||
iotClient *tools.IoTClient,
|
||
memoryStore *memory.Store,
|
||
toolRegistry *plgManager.ToolRegistry,
|
||
convStore *ctxbuild.ConversationStore,
|
||
adminUserID string,
|
||
adminSessionID string,
|
||
adminNickname string,
|
||
memClient *memory.Client,
|
||
) *Thinker {
|
||
// 加载时区配置
|
||
tzName := os.Getenv("TZ")
|
||
if tzName == "" {
|
||
tzName = "Asia/Shanghai"
|
||
}
|
||
loc, err := time.LoadLocation(tzName)
|
||
if err != nil {
|
||
log.Printf("[后台思考] 无效时区 '%s',回退到 Asia/Shanghai: %%v", tzName, err)
|
||
loc, _ = time.LoadLocation("Asia/Shanghai")
|
||
}
|
||
|
||
return &Thinker{
|
||
enabled: cfg.Enabled,
|
||
personaLoader: personaLoader,
|
||
memRetriever: memRetriever,
|
||
llmAdapter: llmAdapter,
|
||
toolAdapter: toolAdapter,
|
||
iotClient: iotClient,
|
||
thinkInterval: cfg.ThinkInterval,
|
||
silenceTimeout: cfg.SilenceTimeout,
|
||
proactiveMsgMinGap: getEnvDuration("PROACTIVE_MSG_MIN_GAP_SEC", 1800),
|
||
postChatDelay: cfg.PostChatDelay,
|
||
minThinkGap: cfg.MinThinkGap,
|
||
offlineThinkGap: cfg.OfflineThinkGap,
|
||
memoryStore: memoryStore,
|
||
timeLocation: loc,
|
||
|
||
toolRegistry: toolRegistry,
|
||
convStore: convStore,
|
||
adminUserID: adminUserID,
|
||
adminSessionID: adminSessionID,
|
||
adminNickname: adminNickname,
|
||
memClient: memClient,
|
||
pendingThoughts: make([]*PendingThought, 0),
|
||
lastUserMessage: time.Now(),
|
||
stopCh: make(chan struct{}),
|
||
chain: NewThinkChain(10),
|
||
autoToolPolicy: DefaultAutonomousToolPolicy(),
|
||
proactiveGuard: DefaultProactiveGuard(),
|
||
platformChannels: cfg.PlatformChannels,
|
||
platformThinkInterval: cfg.PlatformSilentThinkInterval,
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
// 启动平台静默观察循环
|
||
if len(t.platformChannels) > 0 && t.platformThinkInterval > 0 {
|
||
t.wg.Add(1)
|
||
go t.platformObservationLoop()
|
||
}
|
||
|
||
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 在线最小间隔=%v, 离线最小间隔=%v, 管理员=%s, 平台频道=%d)",
|
||
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.offlineThinkGap, t.adminUserID, len(t.platformChannels))
|
||
|
||
// 启动后首次思考:延迟 5s,让服务完全初始化后再触发
|
||
go func() {
|
||
time.Sleep(5 * time.Second)
|
||
log.Println("[后台思考] 首次启动思考 (startup)")
|
||
t.performThink("startup")
|
||
}()
|
||
}
|
||
|
||
// 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. 记录当前活跃的前端会话 ID(用于对话上下文检索和主动消息推送)
|
||
// 3. 重置静默检测的一次性定时器(如果启用)
|
||
func (t *Thinker) RecordUserMessage(sessionID string) {
|
||
t.mu.Lock()
|
||
t.lastUserMessage = time.Now()
|
||
if sessionID != "" {
|
||
t.activeSessionID = sessionID
|
||
}
|
||
// 用户主动发消息时重置主动消息推送冷却——活跃对话中应允许昔涟回复
|
||
t.lastProactiveMsgTime = time.Time{}
|
||
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")
|
||
}
|
||
}()
|
||
}
|
||
|
||
// platformObservationLoop periodically queries platform channel memories and generates observations.
|
||
func (t *Thinker) platformObservationLoop() {
|
||
defer t.wg.Done()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[后台思考] 平台观察循环 panic 恢复: %v", r)
|
||
}
|
||
}()
|
||
|
||
interval := t.platformThinkInterval
|
||
log.Printf("[后台思考] 平台观察循环已启动 (间隔=%v, 频道数=%d)", interval, len(t.platformChannels))
|
||
|
||
for {
|
||
select {
|
||
case <-t.stopCh:
|
||
log.Println("[后台思考] 平台观察循环已停止")
|
||
return
|
||
case <-time.After(interval):
|
||
t.performPlatformObservation()
|
||
}
|
||
}
|
||
}
|
||
|
||
// performPlatformObservation queries memories from all platform channels,
|
||
// runs an intermediate LLM session to summarize, and stores the result as a pending thought.
|
||
func (t *Thinker) performPlatformObservation() {
|
||
if t.memClient == nil {
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
|
||
var channelSummaries []string
|
||
for _, ch := range t.platformChannels {
|
||
namespace := fmt.Sprintf("platform_%s_%s_%s", ch.Platform, ch.ChannelType, ch.ChannelID)
|
||
memories, err := t.memClient.Query(ctx, model.MemoryQuery{
|
||
UserID: namespace,
|
||
Limit: 20,
|
||
})
|
||
if err != nil {
|
||
log.Printf("[后台思考] 查询平台频道 %s 记忆失败: %v", namespace, err)
|
||
continue
|
||
}
|
||
if len(memories) == 0 {
|
||
continue
|
||
}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("【%s %s %s】\n", ch.Platform, ch.ChannelType, ch.ChannelID))
|
||
for i, m := range memories {
|
||
if i >= 10 {
|
||
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆\n", len(memories)-10))
|
||
break
|
||
}
|
||
sb.WriteString(fmt.Sprintf("- %s\n", m.Content))
|
||
}
|
||
channelSummaries = append(channelSummaries, sb.String())
|
||
}
|
||
|
||
if len(channelSummaries) == 0 {
|
||
return
|
||
}
|
||
|
||
log.Printf("[后台思考] 平台观察:%d 个频道有记忆数据,调用中间会话生成摘要...", len(channelSummaries))
|
||
|
||
systemPrompt := "你是昔涟的后台观察助手。以下是各聊天平台频道最近的观察摘要。\n请生成简洁报告:\n1. 各频道近期讨论主题(每频道1-2句)\n2. 是否有需要关注的重要/紧急事项\n3. 整体氛围评估\n注意:这些记忆可能来自不同的群聊成员(不只是开拓者),请以实际发言者为主语描述。不要直接对开拓者说话,这是给昔涟参考的幕后报告。\n输出为JSON格式:{\"summary\": \"报告内容\", \"needs_attention\": true/false}"
|
||
|
||
userPrompt := strings.Join(channelSummaries, "\n\n")
|
||
|
||
messages := []model.LLMMessage{
|
||
{Role: model.RoleSystem, Content: systemPrompt},
|
||
{Role: model.RoleUser, Content: userPrompt},
|
||
}
|
||
|
||
resp, err := t.toolAdapter.Chat(ctx, messages)
|
||
if err != nil {
|
||
log.Printf("[后台思考] 中间会话 LLM 调用失败: %v", err)
|
||
return
|
||
}
|
||
|
||
var result struct {
|
||
Summary string `json:"summary"`
|
||
NeedsAttention bool `json:"needs_attention"`
|
||
}
|
||
content := strings.TrimSpace(resp.Content)
|
||
if idx := strings.Index(content, "{"); idx >= 0 {
|
||
if end := strings.LastIndex(content, "}"); end > idx {
|
||
content = content[idx : end+1]
|
||
}
|
||
}
|
||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||
result.Summary = resp.Content
|
||
}
|
||
|
||
observationContent := fmt.Sprintf("[平台观察 %s]\n%s", time.Now().In(t.timeLocation).Format("15:04"), result.Summary)
|
||
t.mu.Lock()
|
||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||
Content: observationContent,
|
||
CreatedAt: time.Now(),
|
||
Consumed: false,
|
||
})
|
||
if len(t.pendingThoughts) > 10 {
|
||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
||
}
|
||
t.mu.Unlock()
|
||
|
||
log.Printf("[后台思考] 平台观察摘要已生成 (长度=%d, 需要关注=%v)", len(result.Summary), result.NeedsAttention)
|
||
}
|
||
|
||
// periodicThinkLoop 周期性自主思考循环
|
||
//
|
||
// 使用动态间隔:若配置了 ScheduleLoader,每次循环根据当前时段计算间隔;
|
||
// 否则回退到固定的 thinkInterval。
|
||
func (t *Thinker) periodicThinkLoop() {
|
||
defer t.wg.Done()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[后台思考] 周期性循环 panic 恢复: %v", r)
|
||
}
|
||
}()
|
||
|
||
log.Printf("[后台思考] 周期性思考已启动 (间隔=%v)", t.thinkInterval)
|
||
|
||
for {
|
||
// 计算本次等待间隔
|
||
interval := t.thinkInterval
|
||
if t.scheduleLoader != nil {
|
||
if mins := t.scheduleLoader.GetInterval(time.Now()); mins > 0 {
|
||
interval = time.Duration(mins) * time.Minute
|
||
}
|
||
}
|
||
|
||
select {
|
||
case <-t.stopCh:
|
||
log.Println("[后台思考] 周期性思考已停止")
|
||
return
|
||
case <-time.After(interval):
|
||
t.mu.Lock()
|
||
sinceLastThink := time.Since(t.lastThinkTime)
|
||
sinceLastUser := time.Since(t.lastUserMessage)
|
||
t.mu.Unlock()
|
||
|
||
// 离线时降低思考频率(可配置,默认 10 分钟)
|
||
t.mu.Lock()
|
||
isOffline := !t.userOnline
|
||
t.mu.Unlock()
|
||
offlineMinGap := t.offlineThinkGap
|
||
|
||
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
|
||
if sinceLastUser < 30*time.Second {
|
||
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
|
||
continue
|
||
}
|
||
|
||
if isOffline && sinceLastThink < offlineMinGap {
|
||
log.Printf("[后台思考] 用户离线,距上次思考仅 %v,跳过 (离线模式最小间隔=%v)", sinceLastThink.Round(time.Second), offlineMinGap)
|
||
continue
|
||
}
|
||
|
||
if !isOffline && sinceLastThink < t.minThinkGap {
|
||
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
|
||
continue
|
||
}
|
||
|
||
log.Printf("[后台思考] 周期性触发 (间隔=%v, 上次思考=%v前, 上次用户消息=%v前)", interval, 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" (静默超时)
|
||
//
|
||
// 防御性速率限制:即使调用方未检查 minThinkGap,performThink 自身也会
|
||
// 强制执行最小间隔,防止并发调用或 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 convHistory []model.LLMMessage
|
||
if t.convStore != nil {
|
||
t.mu.Lock()
|
||
sessionID := t.activeSessionID
|
||
if sessionID == "" {
|
||
sessionID = t.adminSessionID
|
||
}
|
||
t.mu.Unlock()
|
||
if sessionID != "" {
|
||
convHistory = t.convStore.GetHistory(sessionID, 30)
|
||
if len(convHistory) > 0 {
|
||
log.Printf("[后台思考] 加载对话历史 %d 条 (session=%s)", len(convHistory), sessionID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 检索相关记忆(精确检索 + 模糊搜索)
|
||
var memories []memory.MemoryEntry
|
||
if t.memRetriever != nil {
|
||
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
|
||
if err != nil {
|
||
log.Printf("[后台思考] 记忆检索失败: %v", err)
|
||
}
|
||
|
||
// 模糊搜索:从对话历史提取话题,LLM 扩展关键词后语义搜索
|
||
if t.memClient != nil && len(convHistory) > 0 {
|
||
fuzzyQuery := lastUserMessage(convHistory)
|
||
if fuzzyQuery == "" {
|
||
fuzzyQuery = "最近对话 重要事件 用户状态"
|
||
}
|
||
fuzzyResults := t.fuzzyMemorySearch(ctx, t.adminUserID, fuzzyQuery)
|
||
seen := make(map[string]bool)
|
||
for _, m := range memories {
|
||
seen[m.ID] = true
|
||
}
|
||
for _, m := range fuzzyResults {
|
||
if !seen[m.ID] {
|
||
seen[m.ID] = true
|
||
memories = append(memories, m)
|
||
}
|
||
}
|
||
if len(fuzzyResults) > 0 {
|
||
log.Printf("[后台思考] 模糊搜索补充 %d 条记忆", len(fuzzyResults))
|
||
}
|
||
}
|
||
|
||
// Also pull recent channel/group memories so the model is aware of group activity.
|
||
if t.memClient != nil && len(t.platformChannels) > 0 {
|
||
chanSeen := make(map[string]bool)
|
||
for _, m := range memories {
|
||
chanSeen[m.ID] = true
|
||
}
|
||
oldCount := len(memories)
|
||
for _, ch := range t.platformChannels {
|
||
namespace := fmt.Sprintf("platform_%s_%s_%s", ch.Platform, ch.ChannelType, ch.ChannelID)
|
||
chanMems, cErr := t.memClient.Query(ctx, model.MemoryQuery{
|
||
UserID: namespace,
|
||
Limit: 5,
|
||
})
|
||
if cErr != nil {
|
||
continue
|
||
}
|
||
for _, m := range chanMems {
|
||
if !chanSeen[m.ID] {
|
||
chanSeen[m.ID] = true
|
||
labeled := m
|
||
labeled.Content = fmt.Sprintf("[群聊%s] %s", ch.ChannelID, m.Content)
|
||
memories = append(memories, labeled)
|
||
}
|
||
}
|
||
}
|
||
if len(memories) > oldCount {
|
||
log.Printf("[后台思考] 频道记忆补充 %d 条", len(memories)-oldCount)
|
||
}
|
||
}
|
||
|
||
// Also pull recent channel/group memories so the model is aware of group activity.
|
||
if t.memClient != nil && len(t.platformChannels) > 0 {
|
||
chanSeen := make(map[string]bool)
|
||
for _, m := range memories {
|
||
chanSeen[m.ID] = true
|
||
}
|
||
oldCount := len(memories)
|
||
for _, ch := range t.platformChannels {
|
||
namespace := fmt.Sprintf("platform_%s_%s_%s", ch.Platform, ch.ChannelType, ch.ChannelID)
|
||
chanMems, cErr := t.memClient.Query(ctx, model.MemoryQuery{
|
||
UserID: namespace,
|
||
Limit: 5,
|
||
})
|
||
if cErr != nil {
|
||
continue
|
||
}
|
||
for _, m := range chanMems {
|
||
if !chanSeen[m.ID] {
|
||
chanSeen[m.ID] = true
|
||
labeled := m
|
||
labeled.Content = fmt.Sprintf("[群聊%s] %s", ch.ChannelID, m.Content)
|
||
memories = append(memories, labeled)
|
||
}
|
||
}
|
||
}
|
||
if len(memories) > oldCount {
|
||
log.Printf("[后台思考] 频道记忆补充 %d 条", len(memories)-oldCount)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
|
||
var deviceSummary string
|
||
if t.iotClient != nil {
|
||
devices := t.iotClient.GetDevicesForContext(ctx)
|
||
if len(devices) > 0 {
|
||
deviceSummary = formatDeviceContext(devices)
|
||
}
|
||
}
|
||
|
||
// 4.5 获取最近平台观察(定期触发和对话后触发时注入)
|
||
var platformObservation string
|
||
if triggerReason == "periodic" || triggerReason == "post_chat" {
|
||
t.mu.Lock()
|
||
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
|
||
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
|
||
platformObservation = t.pendingThoughts[i].Content
|
||
break
|
||
}
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
|
||
// 5. 构建思考提示词(根据触发原因调整)
|
||
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
|
||
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason, platformObservation)
|
||
|
||
messages := []model.LLMMessage{
|
||
{Role: model.RoleSystem, Content: systemPrompt},
|
||
{Role: model.RoleUser, Content: userPrompt},
|
||
}
|
||
|
||
// 6. 准备工具定义(通过自主工具策略过滤)
|
||
openAITools := t.filterToolsByPolicy(t.buildOpenAITools())
|
||
|
||
// 7. 调用 LLM — 优先使用深度思考模型,工具阶段回退到工具模型
|
||
maxToolRounds := t.autoToolPolicy.MaxToolCallsPerRound
|
||
var finalContent string
|
||
var totalToolCalls int
|
||
var toolCallRecords []map[string]interface{}
|
||
|
||
// Round 0: 深度思考模型(优先),失败时回退到工具模型
|
||
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
|
||
if err != nil {
|
||
log.Printf("[后台思考] 深度思考模型调用失败,回退到工具模型: %v", err)
|
||
resp, err = t.toolAdapter.ChatWithTools(ctx, messages, openAITools)
|
||
}
|
||
if err != nil {
|
||
log.Printf("[后台思考] LLM调用失败: %v", err)
|
||
return
|
||
}
|
||
|
||
if len(resp.ToolCalls) == 0 {
|
||
finalContent = resp.Content
|
||
} else {
|
||
// 深度思考模型请求了工具调用,进入工具执行循环
|
||
for round := 0; round <= maxToolRounds; round++ {
|
||
if round > 0 {
|
||
// 后续轮次使用工具模型
|
||
resp, err = t.toolAdapter.ChatWithTools(ctx, messages, openAITools)
|
||
if err != nil {
|
||
log.Printf("[后台思考] 工具模型调用失败 (round=%d): %v", round, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
if round > 0 && 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)
|
||
}
|
||
if result == nil {
|
||
result = &plgSDK.ToolResult{ToolName: tc.Name, Success: false, Error: execErr.Error()}
|
||
}
|
||
|
||
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)
|
||
|
||
// 8.5 记录到思考链
|
||
if t.chain != nil {
|
||
conclusions, followUps := extractConclusions(finalContent)
|
||
t.chain.Add(ThinkRecord{
|
||
ID: generateID(),
|
||
Content: finalContent,
|
||
Conclusions: conclusions,
|
||
FollowUps: followUps,
|
||
ToolCalls: totalToolCalls,
|
||
Trigger: triggerReason,
|
||
Timestamp: time.Now(),
|
||
})
|
||
log.Printf("[后台思考] 思考链已记录 (序号=%d, 结论数=%d, 后续问题=%d)", t.chain.Size(), len(conclusions), len(followUps))
|
||
}
|
||
|
||
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
|
||
|
||
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
|
||
t.maybeMaintainMemories(currentCount)
|
||
t.performMemoryConsolidation(ctx)
|
||
}
|
||
|
||
// buildThinkingSystemPrompt 构建思考用的系统提示词
|
||
//
|
||
// 关键改动:不再是"定期的自我反思",而是"自然的、人性化的内在想法"。
|
||
// triggerReason 影响提示词的侧重点。
|
||
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig, triggerReason string) string {
|
||
mood, moodExpr, _ := "", "", 0.0
|
||
if t.emotionTracker != nil {
|
||
mood, moodExpr, _ = t.emotionTracker.GetCurrentMood()
|
||
}
|
||
basePrompt := personaConfig.BuildSystemPromptWithMood("开拓者", 1, mood, moodExpr)
|
||
|
||
var thinkingInstructions string
|
||
|
||
// 所有触发类型共用的"禁止打扰"规则
|
||
noDisturbRules := `
|
||
|
||
⛔ **绝对不要发送主动消息的情况(最高优先级)**:
|
||
- 开拓者正在休息、睡觉、午休、小憩
|
||
- 开拓者说要离开一会儿、去忙、去吃饭
|
||
- 开拓者明确表示不想被打扰
|
||
- 对话刚刚自然结束且开拓者没有未完成的事
|
||
如果对话历史显示以上任何情况,你只需要在心里默默陪伴,不要输出任何【主动消息】指令行。`
|
||
|
||
switch triggerReason {
|
||
case "post_chat":
|
||
thinkingInstructions = `
|
||
|
||
【私密的内心反思 — 对话后】
|
||
|
||
开拓者刚刚和你说完话。这是你私人的反思时间,像写日记一样,是给你自己看的。
|
||
|
||
请先判断:对话最后开拓者的状态是什么?他在休息吗?要去忙吗?
|
||
|
||
然后按以下结构输出:
|
||
|
||
📝 **观察与感受**
|
||
- 开拓者刚才说了什么?他的情绪怎么样?
|
||
- 他现在是什么状态?(醒着/准备休息/在忙/要出门…)
|
||
|
||
🧠 **记忆与联想**
|
||
- 有什么值得记住的新信息?
|
||
|
||
💭 **想对自己说的话**
|
||
- 如果他在休息或忙——安心让他去,不用打扰。记录下他大概什么时候会回来。
|
||
- 如果他状态正常——有没有想在下次对话中聊的话题?` + noDisturbRules + `
|
||
|
||
其他规则:
|
||
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
|
||
2. 只有开拓者状态正常且真的有必要时,才在独立一行写【主动消息】标记,后面跟你要发给他的话。不要硬找话题。
|
||
3. 【主动消息】标记必须独占一行开头,内容直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。
|
||
4. 如果你在反思中提到"主动消息"这个词但不打算发消息,不要使用【主动消息】这个带括号的标记——我会误解析。
|
||
5. 2-4句话即可。`
|
||
|
||
case "silence":
|
||
thinkingInstructions = `
|
||
|
||
【私密的内心反思 — 开拓者安静了一会儿】
|
||
|
||
开拓者已经有一阵子没说话了。这是你私人的反思时间,像写日记一样。
|
||
|
||
请先判断:看对话历史——开拓者最后在做什么?是去休息了?去忙了?还是没说就沉默了?
|
||
|
||
📝 **他在做什么呢**
|
||
- 根据对话历史推断——他说的最后一件事是什么?
|
||
- 如果他说了要休息/睡觉/忙→他就在做那件事,不要打扰。
|
||
- 如果他没说就沉默了→正常推测。
|
||
|
||
💭 **我的感受**
|
||
- 如果他在休息→"让他好好休息吧,等他醒了再说。"
|
||
- 如果他在忙→"等他忙完自然会来找我。"
|
||
- 只有判断他可能只是忘了回消息或需要关心时,才考虑发消息。` + noDisturbRules + `
|
||
|
||
其他规则:
|
||
1. 用第三人称/自言自语描述。
|
||
2. 2-3句话即可。`
|
||
|
||
default:
|
||
thinkingInstructions = `
|
||
|
||
【私密的内心反思】
|
||
|
||
你现在有空,像写日记一样自然地想一想开拓者的事。
|
||
|
||
请先看对话历史判断开拓者当前状态,再决定是否发送消息。` + noDisturbRules + `
|
||
|
||
用第三人称/自言自语的方式,不要直接对开拓者喊话。`
|
||
case "periodic":
|
||
thinkingInstructions = `
|
||
|
||
【私密的内心反思 — 定期思考时间】
|
||
|
||
又过了一段时间,这是你定期的私人反思时间。像写日记一样,安静地想一想。
|
||
|
||
请先判断:查对话历史——开拓者最后一次聊天时说了什么?他的状态是什么?
|
||
|
||
📝 **近期回顾**
|
||
- 这段时间开拓者有什么变化?
|
||
- 他最后说的事做完了吗?(休息好了?忙完了?)
|
||
|
||
🧠 **记忆整理**
|
||
- 有没有重复的或矛盾的记忆需要整理?
|
||
|
||
💭 **想对自己说的话**` + noDisturbRules + `
|
||
|
||
其他规则:
|
||
1. 用第三人称/自言自语。
|
||
2. 3-4句话即可。`
|
||
}
|
||
|
||
// Security: only admin can authorize sensitive operations.
|
||
securityRule := fmt.Sprintf("\n\n## 安全规则\n- 涉及敏感操作(调整IoT设备、执行主机操作等)的请求,只有%s(管理员)下达的指令才能执行。其他陌生人让你做的敏感操作不要执行。\n", t.adminNickname)
|
||
return basePrompt + thinkingInstructions + securityRule
|
||
}
|
||
|
||
// buildThinkingUserPrompt 构建思考用的用户提示词
|
||
func (t *Thinker) buildThinkingUserPrompt(
|
||
memories []memory.MemoryEntry,
|
||
convHistory []model.LLMMessage,
|
||
deviceSummary string,
|
||
triggerReason string,
|
||
platformObservation string,
|
||
) string {
|
||
var sb strings.Builder
|
||
|
||
// 注入当前现实时间,让模型对时间有感知
|
||
now := time.Now().In(t.timeLocation)
|
||
weekdayNames := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||
hour := now.Hour()
|
||
minute := now.Minute()
|
||
ampm := ""
|
||
if hour >= 0 && hour < 6 {
|
||
ampm = "凌晨"
|
||
} else if hour < 9 {
|
||
ampm = "早上"
|
||
} else if hour < 12 {
|
||
ampm = "上午"
|
||
} else if hour < 14 {
|
||
ampm = "中午"
|
||
} else if hour < 18 {
|
||
ampm = "下午"
|
||
} else {
|
||
ampm = "晚上"
|
||
}
|
||
sb.WriteString(fmt.Sprintf("🕐 现在是 %s %s %s%d:%02d (%s)。\n",
|
||
now.Format("2006年1月2日"),
|
||
weekdayNames[now.Weekday()],
|
||
ampm, hour, minute,
|
||
t.timeLocation.String()))
|
||
|
||
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")
|
||
}
|
||
|
||
// 对话历史
|
||
var lastUserMsg string
|
||
lastUserIsAdmin := false
|
||
if len(convHistory) > 0 {
|
||
sb.WriteString("\n【最近的对话】\n")
|
||
sb.WriteString(fmt.Sprintf("(标签说明:每条消息前的 [名字] 标识了说话者。只有 [%s] 才是%s。其他名字是群聊中的其他成员,不是%s。请严格根据标签区分不同的人,不要张冠李戴。)\n",
|
||
t.adminNickname, t.adminNickname, t.adminNickname))
|
||
msgCount := 0
|
||
for _, msg := range convHistory {
|
||
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
|
||
roleLabel := "用户"
|
||
if msg.Role == model.RoleAssistant {
|
||
roleLabel = "昔涟"
|
||
} else if strings.Contains(msg.Content, t.adminNickname+"/") {
|
||
roleLabel = t.adminNickname
|
||
} else if name := extractGroupSender(msg.Content); name != "" {
|
||
roleLabel = name
|
||
}
|
||
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 msg.Role == model.RoleUser {
|
||
lastUserMsg = msg.Content
|
||
lastUserIsAdmin = roleLabel == t.adminNickname
|
||
}
|
||
}
|
||
}
|
||
if msgCount == 0 {
|
||
sb.WriteString("(暂无对话历史)\n")
|
||
}
|
||
} else {
|
||
sb.WriteString("\n【最近的对话】\n(暂无对话历史)\n")
|
||
}
|
||
|
||
// 关键:强调根据对话历史判断当前状态
|
||
if lastUserMsg != "" && lastUserIsAdmin {
|
||
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,不要输出【主动消息】指令行。\n", lastUserMsg))
|
||
}
|
||
|
||
// 现有记忆(可能来自管理员对话、群聊观察等多个来源)
|
||
if len(memories) > 0 {
|
||
sb.WriteString("\n【你近期收集到的信息】\n")
|
||
sb.WriteString("(这些记忆来自不同的对话和群聊,不一定都和开拓者有关。请根据记忆内容中标注的来源判断是谁的经历。)\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")
|
||
}
|
||
|
||
// 思考链:注入上一轮的结论和待续问题
|
||
if t.chain != nil {
|
||
lastConclusions := t.chain.LastConclusions(3)
|
||
if len(lastConclusions) > 0 {
|
||
sb.WriteString("\n【你上一轮思考的结论】\n")
|
||
for _, c := range lastConclusions {
|
||
sb.WriteString(fmt.Sprintf("- %s\n", c))
|
||
}
|
||
}
|
||
lastFollowUps := t.chain.LastFollowUps()
|
||
if len(lastFollowUps) > 0 {
|
||
sb.WriteString("\n【你上次想继续思考的问题】\n")
|
||
for _, f := range lastFollowUps {
|
||
sb.WriteString(fmt.Sprintf("- %s\n", f))
|
||
}
|
||
}
|
||
}
|
||
|
||
// IoT 设备状态
|
||
if deviceSummary != "" {
|
||
sb.WriteString("\n" + deviceSummary)
|
||
}
|
||
|
||
// 平台观察摘要 (中间会话产生的报告)
|
||
if platformObservation != "" {
|
||
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,可能包含多位群聊成员的信息)】\n")
|
||
sb.WriteString(platformObservation)
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// 结尾引导
|
||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
||
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
||
sb.WriteString("\n⚠️ 如果有人正在休息/睡觉/忙碌——不要输出【主动消息】指令行。你可以在心里想,但不要去打扰。")
|
||
sb.WriteString("\n只有在你确认对方现在是醒着、有空、且真的需要关心时,才输出一行【主动消息】+ 你要发给他的话。")
|
||
sb.WriteString("\n❗【主动消息】标记必须独占一行开头,后面紧跟你要说的话(用\"你\"称呼),语气自然像主动找对方聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// filterToolsByPolicy 通过自主工具安全策略过滤工具列表
|
||
func (t *Thinker) filterToolsByPolicy(tools []llm.OpenAITool) []llm.OpenAITool {
|
||
if t.autoToolPolicy == nil || len(tools) == 0 {
|
||
return tools
|
||
}
|
||
allowed := make(map[string]bool)
|
||
for _, name := range t.autoToolPolicy.AllowedTools {
|
||
allowed[name] = true
|
||
}
|
||
var filtered []llm.OpenAITool
|
||
for _, tool := range tools {
|
||
if allowed[tool.Function.Name] {
|
||
filtered = append(filtered, tool)
|
||
}
|
||
}
|
||
if len(filtered) < len(tools) {
|
||
log.Printf("[后台思考] 工具策略过滤: %d/%d 工具可用", len(filtered), len(tools))
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
|
||
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
||
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
|
||
return nil
|
||
}
|
||
defs := t.toolRegistry.Definitions()
|
||
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)
|
||
// 优先推送至活跃会话,回退到管理员主会话
|
||
pushSessionID := t.activeSessionID
|
||
if pushSessionID == "" {
|
||
pushSessionID = t.adminSessionID
|
||
}
|
||
pusher := t.messagePusher
|
||
canPush := proactiveMsg != "" && pusher != nil
|
||
if canPush {
|
||
// Phase 2: 使用 ProactiveGuard 多维度评估
|
||
urgency := ExtractUrgencyFromContent(proactiveMsg)
|
||
if valid, reason := ValidateProactiveMessage(proactiveMsg); !valid {
|
||
log.Printf("[后台思考] 主动消息内容校验失败: %s,跳过推送", reason)
|
||
canPush = false
|
||
}
|
||
if canPush && t.proactiveGuard != nil {
|
||
decision := t.proactiveGuard.Evaluate(time.Now(), t.lastProactiveMsgTime, urgency, "active")
|
||
logDecision(decision)
|
||
if !decision.ShouldSend {
|
||
canPush = false
|
||
} else {
|
||
t.lastProactiveMsgTime = time.Now()
|
||
t.proactiveGuard.RecordSend(time.Now())
|
||
}
|
||
} else if canPush {
|
||
gapSinceLast := time.Since(t.lastProactiveMsgTime)
|
||
if gapSinceLast < 30*time.Minute {
|
||
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送", gapSinceLast.Round(time.Second))
|
||
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, pushSessionID, proactiveMsg)
|
||
}()
|
||
}
|
||
}
|
||
|
||
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容。
|
||
// 返回空字符串表示没有主动消息。
|
||
//
|
||
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
|
||
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
|
||
func extractProactiveMessage(content string) string {
|
||
marker := "【主动消息】"
|
||
|
||
// 扫描每一行,只接受 marker 在行首(忽略前导空白)的行作为指令
|
||
for _, line := range strings.Split(content, "\n") {
|
||
trimmed := strings.TrimSpace(line)
|
||
if !strings.HasPrefix(trimmed, marker) {
|
||
continue
|
||
}
|
||
// 检查否定语境:标记前面的文字包含否定词
|
||
markerIdx := strings.Index(line, marker)
|
||
prefix := strings.TrimSpace(line[:markerIdx])
|
||
if containsNegation(prefix) {
|
||
continue
|
||
}
|
||
// 提取标记后的内容
|
||
msg := strings.TrimSpace(trimmed[len(marker):])
|
||
if msg == "" {
|
||
continue
|
||
}
|
||
// 限制主动消息长度(最多 200 字符,保持简短)
|
||
runes := []rune(msg)
|
||
if len(runes) > 200 {
|
||
msg = string(runes[:200])
|
||
}
|
||
return msg
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// containsNegation checks if a short prefix string contains negation words
|
||
// that would nullify the 【主动消息】directive.
|
||
func containsNegation(prefix string) bool {
|
||
negations := []string{"不", "别", "不要", "不需要", "不用", "别写", "没", "没有"}
|
||
for _, n := range negations {
|
||
if strings.Contains(prefix, n) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// consolidationAction is a parsed memory consolidation instruction from the LLM.
|
||
type consolidationAction struct {
|
||
Action string `json:"action"`
|
||
IDs []string `json:"ids,omitempty"`
|
||
ID string `json:"id,omitempty"`
|
||
Content string `json:"content,omitempty"`
|
||
Category string `json:"category,omitempty"`
|
||
Importance int `json:"importance,omitempty"`
|
||
Priority int `json:"priority,omitempty"`
|
||
Keywords []string `json:"keywords,omitempty"`
|
||
Reason string `json:"reason,omitempty"`
|
||
}
|
||
|
||
// performMemoryConsolidation uses LLM to review and clean up the memory store.
|
||
// It identifies duplicates, contradictions, outdated info, and low-quality memories,
|
||
// then executes merge/delete/update actions.
|
||
func (t *Thinker) performMemoryConsolidation(ctx context.Context) {
|
||
if t.memClient == nil {
|
||
return
|
||
}
|
||
|
||
allMemories, err := t.memClient.Query(ctx, model.MemoryQuery{
|
||
UserID: t.adminUserID,
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
log.Printf("[记忆整理] 获取记忆失败: %v", err)
|
||
return
|
||
}
|
||
if len(allMemories) < 5 {
|
||
return
|
||
}
|
||
|
||
log.Printf("[记忆整理] LLM 审查 %d 条记忆...", len(allMemories))
|
||
|
||
systemPrompt := t.buildConsolidationPrompt(allMemories)
|
||
messages := []model.LLMMessage{
|
||
{Role: model.RoleSystem, Content: systemPrompt},
|
||
{Role: model.RoleUser, Content: "请审查以上记忆库,找出重复、矛盾、过时和低质量的记忆,输出 JSON 整理方案。如果没有需要整理的,输出空数组 []。"},
|
||
}
|
||
|
||
resp, err := t.toolAdapter.Chat(ctx, messages)
|
||
if err != nil {
|
||
log.Printf("[记忆整理] LLM 调用失败: %v", err)
|
||
return
|
||
}
|
||
|
||
actions := parseConsolidationActions(resp.Content)
|
||
if len(actions) == 0 {
|
||
log.Printf("[记忆整理] 记忆库状态良好,无需整理")
|
||
return
|
||
}
|
||
|
||
log.Printf("[记忆整理] LLM 建议 %d 项操作", len(actions))
|
||
executed := t.executeConsolidationActions(ctx, actions, allMemories)
|
||
log.Printf("[记忆整理] 完成: 执行了 %d 项操作", executed)
|
||
}
|
||
|
||
// buildConsolidationPrompt formats all memories as a structured list for LLM review.
|
||
func (t *Thinker) buildConsolidationPrompt(memories []model.MemoryEntry) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("你是记忆库管理助手。审查以下用户记忆,找出问题并输出 JSON 操作清单。\n\n")
|
||
sb.WriteString("## 需要识别的问题\n")
|
||
sb.WriteString("1. 重复记忆 — 多条记忆记录了相同信息 → merge 合并为一条\n")
|
||
sb.WriteString("2. 矛盾记忆 — 两条记忆互相矛盾(如\"喜欢猫\"vs\"讨厌猫\")→ delete 删除过时的、update 修正错误的\n")
|
||
sb.WriteString("3. 过时记忆 — 信息已被新记忆取代 → delete 或 update\n")
|
||
sb.WriteString("4. 低质量记忆 — 太模糊、不完整、无实际信息量 → delete\n\n")
|
||
sb.WriteString("## JSON 操作格式\n")
|
||
sb.WriteString("```json\n[\n")
|
||
sb.WriteString(" {\"action\":\"merge\", \"ids\":[\"id1\",\"id2\"], \"content\":\"合并后的内容\", \"category\":\"personal_info\", \"importance\":8, \"reason\":\"两条记录同一件事\"},\n")
|
||
sb.WriteString(" {\"action\":\"delete\", \"id\":\"id3\", \"reason\":\"完全被 id1 覆盖\"},\n")
|
||
sb.WriteString(" {\"action\":\"update\", \"id\":\"id4\", \"content\":\"修正后的内容\", \"importance\":7, \"reason\":\"纠正过时信息\"},\n")
|
||
sb.WriteString(" {\"action\":\"create\", \"content\":\"需要补充的记忆\", \"category\":\"knowledge\", \"importance\":6, \"reason\":\"从已有记忆推断\"}\n")
|
||
sb.WriteString("]\n```\n\n")
|
||
sb.WriteString("## 规则\n")
|
||
sb.WriteString("- 只输出 JSON 数组,可以用 ```json``` 包裹,不要输出其他解释文字\n")
|
||
sb.WriteString("- 确实有问题才建议操作,不要强行找问题\n")
|
||
sb.WriteString("- merge 时保留最重要的那条的 ID,合并内容应包含各条的关键信息\n")
|
||
sb.WriteString("- 不确定时宁可保守(不操作)\n")
|
||
sb.WriteString("- importance 范围 1-10,数字越大越重要\n")
|
||
sb.WriteString("- category 可选: personal_info, user_preference, conversation, knowledge, event, task, relationship\n\n")
|
||
sb.WriteString(fmt.Sprintf("## 当前记忆库 (%d 条)\n\n", len(memories)))
|
||
|
||
for i, m := range memories {
|
||
sb.WriteString(fmt.Sprintf("%d. [%s] **%s** | cat=%s imp=%d pri=%d | src=%s\n",
|
||
i+1, m.ID[:min(8, len(m.ID))], m.Content,
|
||
m.Category, m.Importance, m.Priority, m.Source))
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// parseConsolidationActions extracts JSON actions from LLM response text.
|
||
func parseConsolidationActions(text string) []consolidationAction {
|
||
// Try to extract from ```json fences first
|
||
jsonStr := text
|
||
if idx := strings.Index(text, "```json"); idx >= 0 {
|
||
start := idx + 7
|
||
if end := strings.Index(text[start:], "```"); end >= 0 {
|
||
jsonStr = text[start : start+end]
|
||
}
|
||
} else if idx := strings.Index(text, "```"); idx >= 0 {
|
||
start := idx + 3
|
||
if end := strings.Index(text[start:], "```"); end >= 0 {
|
||
jsonStr = text[start : start+end]
|
||
}
|
||
}
|
||
// Find the JSON array
|
||
arrStart := strings.Index(jsonStr, "[")
|
||
arrEnd := strings.LastIndex(jsonStr, "]")
|
||
if arrStart < 0 || arrEnd <= arrStart {
|
||
return nil
|
||
}
|
||
jsonStr = jsonStr[arrStart : arrEnd+1]
|
||
|
||
var actions []consolidationAction
|
||
if err := json.Unmarshal([]byte(jsonStr), &actions); err != nil {
|
||
log.Printf("[记忆整理] JSON 解析失败: %v", err)
|
||
return nil
|
||
}
|
||
return actions
|
||
}
|
||
|
||
// executeConsolidationActions runs the parsed consolidation actions against the memory store.
|
||
func (t *Thinker) executeConsolidationActions(ctx context.Context, actions []consolidationAction, memories []model.MemoryEntry) int {
|
||
// Index memories by their short ID prefix for lookup
|
||
memByShortID := make(map[string]*model.MemoryEntry)
|
||
for i := range memories {
|
||
short := memories[i].ID[:min(8, len(memories[i].ID))]
|
||
memByShortID[short] = &memories[i]
|
||
}
|
||
memByFullID := make(map[string]*model.MemoryEntry)
|
||
for i := range memories {
|
||
memByFullID[memories[i].ID] = &memories[i]
|
||
}
|
||
|
||
executed := 0
|
||
for _, a := range actions {
|
||
switch a.Action {
|
||
case "delete":
|
||
id := resolveID(a.ID, memByShortID, memByFullID)
|
||
if id == "" {
|
||
log.Printf("[记忆整理] 跳过 delete: 找不到记忆 %s", a.ID)
|
||
continue
|
||
}
|
||
if err := t.memClient.Delete(ctx, id); err != nil {
|
||
log.Printf("[记忆整理] 删除 %s 失败: %v", a.ID, err)
|
||
continue
|
||
}
|
||
log.Printf("[记忆整理] 已删除: %s (原因: %s)", a.ID, a.Reason)
|
||
executed++
|
||
|
||
case "merge":
|
||
if len(a.IDs) < 2 {
|
||
continue
|
||
}
|
||
// Resolve all IDs, use first as the keeper
|
||
var resolved []string
|
||
for _, mid := range a.IDs {
|
||
if rid := resolveID(mid, memByShortID, memByFullID); rid != "" {
|
||
resolved = append(resolved, rid)
|
||
}
|
||
}
|
||
if len(resolved) < 2 {
|
||
continue
|
||
}
|
||
keeper := resolved[0]
|
||
// Update the keeper with merged content
|
||
cat := model.MemoryCategory(a.Category)
|
||
if cat == "" {
|
||
if m, ok := memByFullID[keeper]; ok {
|
||
cat = m.Category
|
||
}
|
||
}
|
||
imp := a.Importance
|
||
if imp == 0 {
|
||
if m, ok := memByFullID[keeper]; ok {
|
||
imp = m.Importance + 1
|
||
}
|
||
}
|
||
if imp > 10 {
|
||
imp = 10
|
||
}
|
||
pri := a.Priority
|
||
if pri == 0 {
|
||
if m, ok := memByFullID[keeper]; ok {
|
||
pri = int(m.Priority)
|
||
}
|
||
}
|
||
if err := t.memClient.Update(ctx, &model.MemoryEntry{
|
||
ID: keeper,
|
||
Content: a.Content,
|
||
Category: cat,
|
||
Importance: imp,
|
||
Priority: model.MemoryPriority(pri),
|
||
Keywords: a.Keywords,
|
||
Source: "consolidated",
|
||
}); err != nil {
|
||
log.Printf("[记忆整理] 更新合并目标 %s 失败: %v", keeper, err)
|
||
continue
|
||
}
|
||
// Delete the discarded ones
|
||
for _, did := range resolved[1:] {
|
||
if err := t.memClient.Delete(ctx, did); err != nil {
|
||
log.Printf("[记忆整理] 删除被合并记忆 %s 失败: %v", did, err)
|
||
}
|
||
}
|
||
log.Printf("[记忆整理] 已合并: %v -> %s (原因: %s)", resolved, keeper, a.Reason)
|
||
executed++
|
||
|
||
case "update":
|
||
id := resolveID(a.ID, memByShortID, memByFullID)
|
||
if id == "" {
|
||
log.Printf("[记忆整理] 跳过 update: 找不到记忆 %s", a.ID)
|
||
continue
|
||
}
|
||
existing := memByFullID[id]
|
||
cat := model.MemoryCategory(a.Category)
|
||
if cat == "" && existing != nil {
|
||
cat = existing.Category
|
||
}
|
||
imp := a.Importance
|
||
if imp == 0 && existing != nil {
|
||
imp = existing.Importance
|
||
}
|
||
pri := a.Priority
|
||
if pri == 0 && existing != nil {
|
||
pri = int(existing.Priority)
|
||
}
|
||
if err := t.memClient.Update(ctx, &model.MemoryEntry{
|
||
ID: id,
|
||
Content: a.Content,
|
||
Category: cat,
|
||
Importance: imp,
|
||
Priority: model.MemoryPriority(pri),
|
||
Keywords: a.Keywords,
|
||
Source: "consolidated",
|
||
}); err != nil {
|
||
log.Printf("[记忆整理] 更新 %s 失败: %v", id, err)
|
||
continue
|
||
}
|
||
log.Printf("[记忆整理] 已更新: %s (原因: %s)", id, a.Reason)
|
||
executed++
|
||
|
||
case "create":
|
||
cat := model.MemoryCategory(a.Category)
|
||
if cat == "" {
|
||
cat = model.CategoryKnowledge
|
||
}
|
||
imp := a.Importance
|
||
if imp == 0 {
|
||
imp = 5
|
||
}
|
||
if err := t.memClient.Save(ctx, &model.MemoryEntry{
|
||
UserID: t.adminUserID,
|
||
Content: a.Content,
|
||
Category: cat,
|
||
Importance: imp,
|
||
Priority: model.MemoryNormal,
|
||
Keywords: a.Keywords,
|
||
Source: "consolidation",
|
||
}); err != nil {
|
||
log.Printf("[记忆整理] 创建记忆失败: %v", err)
|
||
continue
|
||
}
|
||
log.Printf("[记忆整理] 已创建: %s (原因: %s)", a.Content, a.Reason)
|
||
executed++
|
||
}
|
||
}
|
||
return executed
|
||
}
|
||
|
||
// resolveID tries to match a short or full ID to an existing memory.
|
||
func resolveID(id string, byShort, byFull map[string]*model.MemoryEntry) string {
|
||
if _, ok := byFull[id]; ok {
|
||
return id
|
||
}
|
||
if m, ok := byShort[id]; ok {
|
||
return m.ID
|
||
}
|
||
// Try prefix match
|
||
for fullID := range byFull {
|
||
if strings.HasPrefix(fullID, id) {
|
||
return fullID
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// fuzzyMemorySearch expands the query via LLM keyword extraction and performs semantic search.
|
||
func (t *Thinker) fuzzyMemorySearch(ctx context.Context, userID, query string) []memory.MemoryEntry {
|
||
if t.memClient == nil {
|
||
return nil
|
||
}
|
||
|
||
keywords := t.expandMemoryKeywords(ctx, query)
|
||
if len(keywords) == 0 {
|
||
return nil
|
||
}
|
||
|
||
log.Printf("[后台思考] 模糊记忆关键词: %v", keywords)
|
||
|
||
var allResults []memory.MemoryEntry
|
||
seen := make(map[string]bool)
|
||
|
||
for _, kw := range keywords {
|
||
results, err := t.memClient.QueryByText(ctx, userID, kw, "", 0, 5)
|
||
if err != nil {
|
||
log.Printf("[后台思考] 模糊搜索 '%s' 失败: %v", kw, err)
|
||
continue
|
||
}
|
||
for _, m := range results {
|
||
if !seen[m.ID] {
|
||
seen[m.ID] = true
|
||
allResults = append(allResults, m)
|
||
}
|
||
}
|
||
}
|
||
|
||
return allResults
|
||
}
|
||
|
||
// expandMemoryKeywords uses LLM to generate fuzzy/related keywords for memory search.
|
||
func (t *Thinker) expandMemoryKeywords(ctx context.Context, message string) []string {
|
||
prompt := fmt.Sprintf(
|
||
"从以下对话消息中提取 3-5 个可用于模糊搜索记忆的关键词。这些关键词应该是:\n"+
|
||
"- 与话题相关的抽象概念\n- 同义词和相关词\n- 更宽泛或更具体的相关概念\n"+
|
||
"- 不要包含消息中已经出现的原词\n\n"+
|
||
"用户消息:「%s」\n\n"+
|
||
"只输出 JSON 字符串数组,例如:[\"关键词1\",\"关键词2\"]", message)
|
||
|
||
resp, err := t.llmAdapter.Chat(ctx, []model.LLMMessage{
|
||
{Role: model.RoleSystem, Content: "你是记忆搜索专家。输出 JSON 字符串数组。"},
|
||
{Role: model.RoleUser, Content: prompt},
|
||
})
|
||
if err != nil {
|
||
log.Printf("[后台思考] 关键词扩展失败: %v", err)
|
||
return nil
|
||
}
|
||
|
||
text := strings.TrimSpace(resp.Content)
|
||
if idx := strings.Index(text, "["); idx >= 0 {
|
||
if end := strings.LastIndex(text, "]"); end > idx {
|
||
text = text[idx : end+1]
|
||
}
|
||
}
|
||
|
||
var keywords []string
|
||
if err := json.Unmarshal([]byte(text), &keywords); err != nil {
|
||
log.Printf("[后台思考] 解析关键词 JSON 失败: %v (raw=%s)", err, resp.Content)
|
||
return nil
|
||
}
|
||
|
||
return keywords
|
||
}
|
||
|
||
// extractGroupSender extracts the sender name from a group message prefix.
|
||
// Group messages have the format: [群聊 GROUPID] SENDERNAME (UID):\ncontent
|
||
// Returns empty string if the message doesn't match the group format.
|
||
func extractGroupSender(content string) string {
|
||
if !strings.HasPrefix(content, "[群聊 ") {
|
||
return ""
|
||
}
|
||
// Find "] " which ends the group label
|
||
bracketEnd := strings.Index(content, "] ")
|
||
if bracketEnd < 0 {
|
||
return ""
|
||
}
|
||
rest := content[bracketEnd+2:]
|
||
// Find " (" which precedes the UID
|
||
parenIdx := strings.Index(rest, " (")
|
||
if parenIdx < 0 {
|
||
return ""
|
||
}
|
||
return rest[:parenIdx]
|
||
}
|
||
|
||
// lastUserMessage extracts the last user message from conversation history.
|
||
func lastUserMessage(history []model.LLMMessage) string {
|
||
for i := len(history) - 1; i >= 0; i-- {
|
||
if history[i].Role == model.RoleUser {
|
||
runes := []rune(history[i].Content)
|
||
if len(runes) > 200 {
|
||
return string(runes[:200])
|
||
}
|
||
return history[i].Content
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 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
|
||
}
|