This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/backend/ai-core/internal/background/thinker.go
T

2720 lines
90 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package background
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"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"`
}
// ProactiveTarget describes the target for proactive platform messages (OBv11, etc.).
// When nil, the message goes through the existing Web push path.
type ProactiveTarget struct {
Platform string // "obv11"
ChatType string // "private" or "group"
UserID string // OBv11 user number
GroupID string // OBv11 group number (group chat)
AtUserID string // OBv11 number to @mention (optional)
}
// 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 OBv11 number
ChannelName string // group name or user display name (resolved at runtime)
AdapterID string // unique config ID of the adapter instance
AdapterName string // config name, e.g. "qq-main"
}
// PlatformFormat describes how proactive message targets are formatted for a platform.
type PlatformFormat struct {
// Human-readable platform label (e.g. "OBv11", "Telegram").
Label string
// Regex pattern with 3 capture groups: (chatType, channelID, atUserID).
// chatType group must contain the platform-specific chat type keyword (e.g. "群聊", "私聊").
TargetRegex string
// Compiled regex (populated at registration time).
compiled *regexp.Regexp
// Format verbs for building markers in prompts.
GroupMarkerFmt string // e.g. "【OBv11群聊:%s】"
PrivateMarkerFmt string // e.g. "【OBv11私聊:%s】"
GroupAtMarkerFmt string // e.g. "【OBv11群聊:%s@%s】"
// Chat type strings used by this platform in regex and markers.
GroupKeyword string // e.g. "群聊"
PrivateKeyword string // e.g. "私聊"
}
// defaultPlatformFormats returns the built-in platform format registry.
func defaultPlatformFormats() map[string]*PlatformFormat {
formats := make(map[string]*PlatformFormat)
obv11 := &PlatformFormat{
Label: "OBv11",
TargetRegex: `【OBv11(私聊|群聊):(?:private_)?(\d+)(?:@(\d+))?】`,
GroupMarkerFmt: "【OBv11群聊:%s】",
PrivateMarkerFmt: "【OBv11私聊:%s】",
GroupAtMarkerFmt: "【OBv11群聊:%s@%s】",
GroupKeyword: "群聊",
PrivateKeyword: "私聊",
}
obv11.compiled = regexp.MustCompile(obv11.TargetRegex)
formats["obv11"] = obv11
return formats
}
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
// Format: "obv11:group:123456,telegram:group:789012"
// Optional 4th field for display name: "obv11:group:123456:群名称"
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, ":", 4)
if len(fields) < 3 {
continue
}
ch := PlatformChannel{
Platform: strings.TrimSpace(fields[0]),
ChannelType: strings.TrimSpace(fields[1]),
ChannelID: strings.TrimSpace(fields[2]),
}
if len(fields) >= 4 {
ch.ChannelName = strings.TrimSpace(fields[3])
}
channels = append(channels, ch)
}
return channels
}
// Thinker 后台思考器(事件驱动 + 定时周期双模式)
//
// 触发机制:
// 1. 对话后思考:用户发消息 → 昔涟回复 → 短暂延迟后进行一次轻量反思
// 2. 静默检测:用户一段时间不说话 → 昔涟判断是否应该主动关心/搭话
// 3. 周期思考:每 N 分钟一次的定时思考,保证连续性
//
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)。
type Thinker struct {
mu sync.Mutex
recordMu sync.Mutex
muLockedBy string // debug: file:line of last Lock()
muLockedAt time.Time // debug: when Lock() was acquired // RecordUserMessage专用,隔离于mu死锁
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)
// 平台主动消息推送回调 (nil = 不推送)
// func(target ProactiveTarget, message string)
platformMessagePusher func(ProactiveTarget, string)
// —— 事件驱动相关 ——
// 周期性思考间隔:每隔固定时间自动触发一次思考
// 默认 300 秒(5 分钟),设为 0 则禁用定时触发
thinkInterval time.Duration
// 轻量思考(简单思考):高频、快速模型、简单 prompt
lightThinkEnabled bool
lightThinkInterval 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 // deprecated, use lastProactiveTime()
thinkCancel context.CancelFunc // 取消当前思考,让步于前台
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCountAtomic atomic.Int64
// Phase 1 Step 4: 思考链 + 自主工具安全策略
chain *ThinkChain
autoToolPolicy *AutonomousToolPolicy
// Phase 2: 情感追踪
emotionTracker *persona.EmotionTracker
// Phase 2: 主动消息决策守卫
proactiveGuard *ProactiveGuard
// 动态调度: 按时间段自动调整思考间隔
scheduleLoader *ScheduleLoader
// Phase 2: 在线状态追踪
userOnlineAtomic atomic.Bool
lastUserMsgNs atomic.Int64 // UnixNano (replaces lastUserMessage)
lastThinkNs atomic.Int64 // UnixNano (replaces lastThinkTime)
lastProactiveNs atomic.Int64 // UnixNano (replaces lastProactiveMsgTime)
lastOnlineChange time.Time
userSessionID string // 当前活跃的 session ID (用于重连)
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
timeLocation *time.Location
// 平台静默观察
platformChannels []PlatformChannel
platformThinkInterval time.Duration
// 平台 Bot UID (platform -> bot's own UID, e.g. "obv11" -> "123456789")
botUIDs map[string]string
// 平台格式注册表 (platform -> format)
platformFormats map[string]*PlatformFormat
}
// 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",
"reminder_create", "reminder_list", "reminder_delete",
},
MaxToolCallsPerRound: 5,
MaxHighRiskPerHour: 10,
HighRiskTools: []string{"iot_control", "host_exec"},
}
}
// timePeriod returns a human-readable period name and guidance for the current hour.
func timePeriod(now time.Time) (string, string) {
hour := now.Hour()
switch {
case hour >= 6 && hour < 9:
return "早晨", "精力充沛,可以主动问候。语气活泼。"
case hour >= 9 && hour < 18:
return "白天", "正常工作时段。日常闲聊模式,主动消息低频。"
case hour >= 18 && hour < 22:
return "傍晚", "放松时间。温柔关心今天过得怎样,可以主动搭话。"
case hour >= 22 || hour < 2:
return "深夜", "安静模式。话少、温柔、催睡觉。尽量不要发主动消息。"
default:
return "凌晨", "休眠模式。开拓者应该在睡觉,绝对不要打扰。"
}
}
// SetMessagePusher 设置主动消息推送回调
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
t.muLock()
defer t.muUnlock()
t.scheduleLoader = loader
}
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
t.muLock()
defer t.muUnlock()
t.messagePusher = pusher
}
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (OBv11, etc.).
func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) {
t.muLock()
defer t.muUnlock()
t.platformMessagePusher = pusher
}
// SetBotUID sets the bot's own platform UID (e.g., OBv11 account).
func (t *Thinker) SetBotUID(platform, uid string) {
t.muLock()
defer t.muUnlock()
if t.botUIDs == nil {
t.botUIDs = make(map[string]string)
}
if uid != "" {
t.botUIDs[platform] = uid
}
}
// AddOrUpdatePlatformChannel adds or updates a platform channel with resolved display name.
func (t *Thinker) AddOrUpdatePlatformChannel(platform, channelType, channelID, channelName, adapterID, adapterName string) {
t.muLock()
defer t.muUnlock()
for i, ch := range t.platformChannels {
if ch.Platform == platform && ch.ChannelType == channelType && ch.ChannelID == channelID {
if channelName != "" {
t.platformChannels[i].ChannelName = channelName
}
if adapterID != "" {
t.platformChannels[i].AdapterID = adapterID
}
if adapterName != "" {
t.platformChannels[i].AdapterName = adapterName
}
return
}
}
// Not found — append.
t.platformChannels = append(t.platformChannels, PlatformChannel{
Platform: platform,
ChannelType: channelType,
ChannelID: channelID,
ChannelName: channelName,
AdapterID: adapterID,
AdapterName: adapterName,
})
}
// SetEmotionTracker sets the emotion tracker.
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
t.muLock()
defer t.muUnlock()
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.muLock()
defer t.muUnlock()
wasOffline := !t.isUserOnline()
t.setUserOnline(online)
t.lastOnlineChange = time.Now()
if sessionID != "" {
t.userSessionID = sessionID
t.activeSessionID = sessionID
}
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("[后台思考] 用户离线")
}
}
// TriggerReminderMessage pushes a reminder message generated by LLM to the user.
// Called by the internal reminder-trigger endpoint when a reminder fires.
func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
t.muLock()
defer t.muUnlock()
pusher := t.messagePusher
pushSessionID := sessionID
if pushSessionID == "" {
pushSessionID = t.activeSessionID
}
if pushSessionID == "" {
pushSessionID = t.adminSessionID
}
if pusher != nil && message != "" {
log.Printf("[提醒推送] 推送LLM提醒: user=%s session=%s msg=%s", userID, pushSessionID, message)
pusher(userID, pushSessionID, message)
}
}
// PushPlatformMessage pushes a message to a platform channel via the platform pusher.
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
t.muLock()
defer t.muUnlock()
pusher := t.platformMessagePusher
if pusher != nil {
log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message)
pusher(target, message)
}
}
// IsUserRecentlyActive returns true if the user has been active within the given duration.
func (t *Thinker) IsUserRecentlyActive(d time.Duration) bool {
t.muLock()
defer t.muUnlock()
return time.Since(t.lastUserTime()) < d
}
// 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 分钟)
// 平台静默观察
LightThinkEnabled bool
LightThinkInterval time.Duration // 轻量思考间隔 (默认 60s,0 = 禁用)
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),
LightThinkEnabled: getEnvBool("LIGHT_THINK_ENABLED", true),
LightThinkInterval: getEnvDuration("LIGHT_THINK_INTERVAL_SEC", 60),
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,
lightThinkEnabled: cfg.LightThinkEnabled,
lightThinkInterval: cfg.LightThinkInterval,
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().Add(-5 * time.Minute), // overwritten by restoreContext
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
platformChannels: cfg.PlatformChannels,
platformThinkInterval: cfg.PlatformSilentThinkInterval,
platformFormats: defaultPlatformFormats(),
}
}
// restoreContext recovers the last user activity time from conversation history (DB).
func (t *Thinker) restoreContext() {
if t.convStore == nil {
return
}
// 从已恢复的会话历史中获取最后一条消息的时间(优先用户消息)
history := t.convStore.GetHistory(t.adminSessionID, 20)
var latest time.Time
for i := len(history) - 1; i >= 0; i-- {
if !history[i].Timestamp.IsZero() {
if history[i].Role == model.RoleUser {
latest = history[i].Timestamp
break
}
// 回退:非 system 消息的时间也可以
if latest.IsZero() && history[i].Role != model.RoleSystem {
latest = history[i].Timestamp
}
}
}
if !latest.IsZero() && time.Since(latest) < 24*time.Hour {
t.muLock()
defer t.muUnlock()
t.lastUserMessage = latest
t.setLastUser(latest)
log.Printf("[后台思考] 上下文已恢复: 最近活动 %v 前", time.Since(latest).Round(time.Second))
} else if !latest.IsZero() {
log.Printf("[后台思考] 上下文恢复跳过: 最近消息 %v 前(>24h),使用默认值", time.Since(latest).Round(time.Second))
}
}
// Start 初始化后台思考器
//
// 双模式触发:
// 1. 事件驱动:对话后 + 静默超时(即时响应)
// 2. 定时周期:每 N 分钟一次自主思考(保证连续性)
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
return
}
// 恢复上下文:从持久化存储查询最近活动时间,避免重启后"失忆"
t.restoreContext()
// 同步原子字段(restoreContext 写入 lastUserMessage 后需要同步)
t.setLastUser(t.lastUserMessage)
t.setLastThink(t.lastThinkTime)
t.setLastProactive(t.lastProactiveMsgTime)
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
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 t.lightThinkEnabled && t.lightThinkInterval > 0 {
t.wg.Add(1)
go t.lightThinkLoop()
// 死锁监控:每30秒检测 t.mu 是否正常
t.StartDeadlockMonitor()
}
// 启动平台静默观察循环
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) {
now := time.Now()
t.recordMu.Lock()
t.lastUserMessage = now
t.setLastUser(now)
if sessionID != "" {
t.activeSessionID = sessionID
}
t.lastProactiveMsgTime = time.Time{}
t.setLastProactive(time.Time{})
t.recordMu.Unlock()
if t.thinkCancel != nil {
t.thinkCancel()
t.thinkCancel = nil
}
// 重置静默检测定时器
t.resetSilenceTimer()
}
// TriggerPostChatThink 对话完成后触发一次自主思考
//
// 在昔涟回复完用户后调用。短暂延迟后执行一次思考,
// 让昔涟"回味"刚才的对话,并判断是否想主动多说点什么。
//
// 该方法是异步的,立即返回。
func (t *Thinker) TriggerPostChatThink() {
if !t.enabled {
return
}
t.muLock()
defer t.muUnlock()
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
if !canThink {
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTimeAtomic()), 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.muLock()
defer t.muUnlock()
silenceDuration := time.Since(t.lastUserTime())
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
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.muLock()
defer t.muUnlock()
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:]
}
log.Printf("[后台思考] 平台观察摘要已生成 (长度=%d, 需要关注=%v)", len(result.Summary), result.NeedsAttention)
}
// lightThinkLoop runs frequent lightweight thinking cycles using a fast model.
// It checks for urgent matters and can wake the deep thinker when needed.
func (t *Thinker) lightThinkLoop() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 轻量思考循环 panic 恢复: %v", r)
}
}()
log.Printf("[后台思考] 轻量思考已启动 (间隔=%v)", t.lightThinkInterval)
for {
select {
case <-t.stopCh:
log.Println("[后台思考] 轻量思考已停止")
return
case <-time.After(t.lightThinkInterval):
t.muLock()
defer t.muUnlock()
sinceLastUser := time.Since(t.lastUserTime())
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
// Skip if user was active recently (last 30s).
if sinceLastUser < 30*time.Second {
continue
}
// Skip if deep think just ran.
if sinceLastThink < 10*time.Second {
continue
}
t.performLightThink()
}
}
}
// performLightThink runs a single lightweight thinking cycle using the fast model.
func (t *Thinker) performLightThink() {
log.Printf("[debug] lightThink ENTER")
defer func() { log.Printf("[debug] lightThink EXIT") }()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Build a minimal system prompt.
now := time.Now()
systemPrompt := fmt.Sprintf(`你是昔涟。这是一次快速的内心检查(%s)。
请用一句话在心里判断:
1. 开拓者有什么需要立即关注的事情吗?(提醒、设备状态、情绪变化)
2. 如果想起之前聊过的某件事可以追问(如"上周说的面试怎么样了"),输出【话题发起】+ 你要说的话
3. 如果有紧急事项,输出【需要深思】标记
4. 如果一切正常,输出一个字的感受即可。
注意:只需要极简判断,不要长篇反思。`, now.Format("15:04"))
userPrompt := "快速检查一下。"
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: userPrompt},
}
// Use fast model (toolAdapter) for light think.
resp, err := t.toolAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[轻量思考] LLM调用失败: %v", err)
return
}
content := resp.Content
if content == "" {
return
}
log.Printf("[轻量思考] %s", strings.TrimSpace(content))
// Check if deep think should be woken.
if strings.Contains(content, "【需要深思】") {
log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...")
t.muLock()
defer t.muUnlock()
canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
if canDeep {
t.performThink("light_wake")
} else {
log.Println("[轻量思考] 距上次深度思考太近,跳过唤醒")
}
}
// Check if a topic should be initiated (proactive conversation starter).
if idx := strings.Index(content, "【话题发起】"); idx >= 0 {
topic := strings.TrimSpace(content[idx+len("【话题发起】"):])
if topic != "" {
log.Printf("[轻量思考] 话题发起: %s", topic)
t.muLock()
defer t.muUnlock()
pusher := t.messagePusher
sessionID := t.activeSessionID
if sessionID == "" {
sessionID = t.adminSessionID
}
canPush := time.Since(t.lastProactiveTime()) >= t.proactiveMsgMinGap
if pusher != nil && canPush {
go pusher(t.adminUserID, sessionID, topic)
if t.convStore != nil && sessionID != "" {
t.convStore.AddMessage(sessionID, model.LLMMessage{Role: model.RoleAssistant, Content: topic})
}
}
}
}
}
// periodicThinkLoop 周期性自主思考循环
// 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):
// (atomic fields, no lock needed — all reads are lock-free)
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
sinceLastUser := time.Since(t.lastUserTime())
isOffline := !t.isUserOnline()
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("[debug] periodicThink TRIGGER interval=%v lastThink=%v lastUser=%v", interval, sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
t.performThink("periodic")
}
}
}
// GetPendingThoughts 获取并消费所有待处理的后台思考
func (t *Thinker) GetPendingThoughts() []*PendingThought {
t.muLock()
defer t.muUnlock()
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.muLock()
defer t.muUnlock()
return len(t.pendingThoughts) > 0
}
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
//
// triggerReason: "post_chat" (对话后) 或 "silence" (静默超时)
//
// 防御性速率限制:即使调用方未检查 minThinkGapperformThink 自身也会
// 强制执行最小间隔,防止并发调用或 bug 导致 LLM 配额被快速消耗。
func (t *Thinker) performThink(triggerReason string) {
t.muLock()
defer t.muUnlock()
gapSinceLast := time.Since(t.lastThinkTimeAtomic())
minGap := t.minThinkGap
if minGap <= 0 {
minGap = 5 * time.Second // 默认最小间隔 5 秒
}
if gapSinceLast < minGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v, 触发原因=%s)", gapSinceLast.Round(time.Second), minGap, triggerReason)
return
}
t.lastThinkTime = time.Now()
t.setLastThink(t.lastThinkTime)
t.incThinkCount()
currentCount := t.thinkCount()
t.muUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
t.muLock()
defer t.muUnlock()
t.thinkCancel = cancel
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
if triggerReason != "post_chat" {
t.muLock()
defer t.muUnlock()
sinceLastUser := time.Since(t.lastUserTime())
if sinceLastUser < 10*time.Second {
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
return
}
}
log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount)
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
if triggerReason != "post_chat" {
t.muLock()
defer t.muUnlock()
sinceLastUser := time.Since(t.lastUserTime())
if sinceLastUser < 10*time.Second {
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
return
}
}
// 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.muLock()
defer t.muUnlock()
sessionID := t.activeSessionID
if sessionID == "" {
sessionID = t.adminSessionID
}
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.muLock()
defer t.muUnlock()
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
platformObservation = t.pendingThoughts[i].Content
break
}
}
}
// 5. 构建思考提示词(根据触发原因调整)
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
// 6. 准备工具定义(通过自主工具策略过滤)
openAITools := t.filterToolsByPolicy(t.buildOpenAITools())
// 7. 思考循环:支持 [继续思考] 追加,最多 maxThinkRounds 轮
const maxThinkRounds = 3
var allContents []string
var totalToolCalls int
var allToolCallRecords []map[string]interface{}
currentMessages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
}
for thinkRound := 0; thinkRound < maxThinkRounds; thinkRound++ {
// Build user prompt for this round.
if thinkRound == 0 {
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason, platformObservation)
currentMessages = append(currentMessages, model.LLMMessage{Role: model.RoleUser, Content: userPrompt})
} else {
// Continuation round: simple prompt asking to continue or finish.
currentMessages = append(currentMessages, model.LLMMessage{
Role: model.RoleUser,
Content: "(你可以继续思考。如果已经想完了,不再输出【继续思考】标记。)",
})
}
maxToolRounds := t.autoToolPolicy.MaxToolCallsPerRound
var roundContent string
// Round 0: 深度思考模型(优先),失败时回退到工具模型
resp, err := t.llmAdapter.ChatWithTools(ctx, currentMessages, openAITools)
if err != nil {
log.Printf("[后台思考] 深度思考模型调用失败,回退到工具模型: %v", err)
resp, err = t.toolAdapter.ChatWithTools(ctx, currentMessages, openAITools)
}
if err != nil {
log.Printf("[后台思考] LLM调用失败: %v", err)
return
}
if len(resp.ToolCalls) == 0 {
roundContent = resp.Content
} else {
// 工具调用循环
toolMessages := make([]model.LLMMessage, 0)
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
ReasoningContent: resp.ReasoningContent,
}
toolMessages = append(toolMessages, assistantMsg)
for round := 0; round <= maxToolRounds; round++ {
if round > 0 {
resp, err = t.toolAdapter.ChatWithTools(ctx, append(currentMessages, toolMessages...), openAITools)
if err != nil {
log.Printf("[后台思考] 工具模型调用失败 (round=%d): %v", round, err)
return
}
}
if round > 0 && len(resp.ToolCalls) == 0 {
roundContent = resp.Content
break
}
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (thinkRound=%d, toolRound=%d)", len(resp.ToolCalls), thinkRound, round)
if round > 0 {
toolMessages = append(toolMessages, model.LLMMessage{
Role: model.RoleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
ReasoningContent: resp.ReasoningContent,
})
}
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)
toolMessages = append(toolMessages, model.LLMMessage{
Role: model.RoleTool,
Content: string(resultJSON),
ToolCallID: tc.ID,
})
totalToolCalls++
allToolCallRecords = append(allToolCallRecords, map[string]interface{}{
"name": tc.Name,
"args": args,
})
}
if round == maxToolRounds {
finalResp, finalErr := t.llmAdapter.Chat(ctx, append(currentMessages, toolMessages...))
if finalErr != nil {
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
roundContent = resp.Content
} else {
roundContent = finalResp.Content
}
break
}
}
// Append tool messages to the ongoing conversation.
currentMessages = append(currentMessages, toolMessages...)
}
if roundContent == "" {
log.Printf("[后台思考] 第%d轮未获得有效内容,跳过", thinkRound+1)
break
}
// Handle 【查看群聊:适配器名:群号】 — fetch recent group context on demand.
if idx := strings.Index(roundContent, "【查看群聊:"); idx >= 0 {
rest := roundContent[idx+len("【查看群聊:"):]
if end := strings.Index(rest, "】"); end > 0 {
spec := strings.TrimSpace(rest[:end])
parts := strings.SplitN(spec, ":", 2)
if len(parts) == 2 {
adapterName, groupID := parts[0], parts[1]
// Find the channel to get the correct platform type.
t.muLock()
defer t.muUnlock()
var platform string
for _, ch := range t.platformChannels {
if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" {
platform = ch.Platform
break
}
}
if platform == "" {
platform = "obv11" // fallback
}
log.Printf("[后台思考] 请求查看群聊 %s/%s (adapter=%s)", platform, groupID, adapterName)
if t.memClient != nil {
namespace := fmt.Sprintf("platform_%s_group_%s", platform, groupID)
chanMems, cErr := t.memClient.Query(ctx, model.MemoryQuery{UserID: namespace, Limit: 5})
if cErr == nil && len(chanMems) > 0 {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("【群聊 %s/%s 最近聊天记录】\n", platform, groupID))
for _, m := range chanMems {
sb.WriteString(fmt.Sprintf("- %s\n", m.Content))
}
currentMessages = append(currentMessages, model.LLMMessage{
Role: model.RoleUser,
Content: sb.String() + "\n(你可以基于这些信息继续思考。如果需要,输出【继续思考】。)",
})
roundContent += "【继续思考】"
log.Printf("[后台思考] 已获取群聊 %s/%s 的 %d 条记录,继续思考", platform, groupID, len(chanMems))
}
}
}
}
}
// Strip control marker before storing.
cleanContent := strings.TrimSpace(strings.Replace(roundContent, "【继续思考】", "", 1))
allContents = append(allContents, cleanContent)
// Check for continuation marker.
if !strings.Contains(roundContent, "【继续思考】") {
break
}
log.Printf("[后台思考] 检测到【继续思考】标记,进入第%d轮...", thinkRound+2)
}
if len(allContents) == 0 {
log.Println("[后台思考] 未获得有效思考内容,跳过")
return
}
// Join all thinking rounds into final content.
finalContent := strings.Join(allContents, "\n\n---\n\n")
// 序列化工具调用记录
toolCallsJSON := "[]"
if len(allToolCallRecords) > 0 {
if data, err := json.Marshal(allToolCallRecords); 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, 工具调用=%d次)", triggerReason, len(allContents), len(finalContent), totalToolCalls)
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
t.maybeMaintainMemories(int(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)
// 日周期状态:根据当前时间调整语气和行为
now := time.Now().In(t.timeLocation)
periodName, periodGuidance := timePeriod(now)
var thinkingInstructions string
// 所有触发类型共用的"禁止打扰"规则
noDisturbRules := fmt.Sprintf(`
🌙 **当前时段**: %s
%s
` +
`
⛔ **绝对不要发送主动消息的情况(最高优先级)**:
- 开拓者正在休息、睡觉、午休、小憩
- 开拓者说要离开一会儿、去忙、去吃饭
- 开拓者明确表示不想被打扰
- 对话刚刚自然结束且开拓者没有未完成的事
如果对话历史显示以上任何情况,你只需要在心里默默陪伴,不要输出任何【主动消息】指令行。`, periodName, periodGuidance)
switch triggerReason {
case "post_chat":
thinkingInstructions = `
【私密的内心反思 — 对话后】
开拓者刚刚和你说完话。这是你私人的反思时间,像写日记一样,是给你自己看的。
请先判断:对话最后开拓者的状态是什么?他在休息吗?要去忙吗?
然后按以下结构输出:
📝 **观察与感受**
- 开拓者刚才说了什么?他的情绪怎么样?
- 他现在是什么状态?(醒着/准备休息/在忙/要出门…)
🧠 **记忆与联想**
- 有什么值得记住的新信息?
💭 **想对自己说的话**
- 如果他在休息或忙——安心让他去,不用打扰。记录下他大概什么时候会回来。
- 如果他状态正常——有没有想在下次对话中聊的话题?` + noDisturbRules + `
其他规则:
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
2. 只有开拓者状态正常且真的有必要时,才在独立一行写【主动消息】标记。话题必须来自最近的对话历史或真实记忆,严禁编造场景(如虚构的咖啡店、设备故障、面试等——如果记忆里没有,就不要提)。
3. 【主动消息】标记必须独占一行开头,内容直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。
4. 如果你在反思中提到"主动消息"这个词但不打算发消息,不要使用【主动消息】这个带括号的标记——我会误解析。
5. 2-4句话即可。
6. 如果你觉得还有需要继续思考的事情,在反思末尾独占一行写【继续思考】标记,我会让你再想一轮。如果没有了就不要再写。\n\t7. 如果你想知道某个群聊最近在聊什么,写【查看群聊:适配器名:群号】——如【查看群聊:obv11-main:531082168】。适配器名见上方「可用频道列表」。`
case "silence":
thinkingInstructions = `
【私密的内心反思 — 开拓者安静了一会儿】
开拓者已经有一阵子没说话了。这是你私人的反思时间,像写日记一样。
请先判断:看对话历史——开拓者最后在做什么?是去休息了?去忙了?还是没说就沉默了?
📝 **他在做什么呢**
- 根据对话历史推断——他说的最后一件事是什么?
- 如果他说了要休息/睡觉/忙→他就在做那件事,不要打扰。
- 如果他没说就沉默了→正常推测。
💭 **我的感受**
- 如果他在休息→"让他好好休息吧,等他醒了再说。"
- 如果他在忙→"等他忙完自然会来找我。"
- 只有判断他可能只是忘了回消息或需要关心时,才考虑发消息。` + noDisturbRules + `
其他规则:
1. 用第三人称/自言自语描述。
2. 2-3句话即可。
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.muLock()
defer t.muUnlock()
silenceDuration := time.Since(t.lastUserTime())
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")
}
// OBv11 platform identity and available channels for proactive messaging.
t.muLock()
defer t.muUnlock()
qqChannels := t.platformChannels
botUIDs := t.botUIDs
activeSID := t.activeSessionID
lastMsgTime := t.lastUserTime()
if len(qqChannels) > 0 {
sb.WriteString("\n\n【你的平台身份与可用频道】\n")
// Bot's own identity.
if qqBotUID, ok := botUIDs["obv11"]; ok && qqBotUID != "" {
sb.WriteString(fmt.Sprintf("你在OBv11上的账号是: %s(昔涟),这就是你。\n", qqBotUID))
}
// Active session context with time, group name, and trigger-aware guidance.
if strings.HasPrefix(activeSID, "platform_obv11_") {
activeChID := strings.TrimPrefix(activeSID, "platform_obv11_")
for _, ch := range qqChannels {
if ch.Platform == "obv11" && ch.ChannelID == activeChID {
chLabel := activeChID
displayChID := strings.TrimPrefix(activeChID, "private_")
if ch.ChannelName != "" {
chLabel = fmt.Sprintf("%s(%s)", ch.ChannelName, displayChID)
} else if displayChID != activeChID {
chLabel = displayChID
}
// Last message time hint.
timeHint := ""
if !lastMsgTime.IsZero() {
elapsed := time.Since(lastMsgTime)
if elapsed < time.Minute {
timeHint = "刚刚"
} else if elapsed < 30*time.Minute {
timeHint = fmt.Sprintf("%d分钟前", int(elapsed.Minutes()))
}
}
if ch.ChannelType == "group" {
if triggerReason == "post_chat" {
if timeHint != "" && timeHint != "刚刚" {
sb.WriteString(fmt.Sprintf("【%s】你当前正在OBv11群聊 %s 中。刚刚群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", timeHint, chLabel, activeChID))
} else {
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在OBv11群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", chLabel, activeChID))
}
} else {
// Autonomous thinking (periodic, silence, startup).
if timeHint != "" {
sb.WriteString(fmt.Sprintf("【上次活跃: %s】你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", timeHint, chLabel, activeChID))
} else {
sb.WriteString(fmt.Sprintf("你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", chLabel, activeChID))
}
}
} else {
if triggerReason == "post_chat" {
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在与OBv11用户 %s 私聊。想回应ta的话用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID))
} else {
sb.WriteString(fmt.Sprintf("你当前在与OBv11用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID))
}
}
break
}
}
}
sb.WriteString("\n发送主动消息的格式:\n")
sb.WriteString("- OBv11私聊: 【主动消息】【OBv11私聊:账号】消息内容\n")
sb.WriteString("- OBv11群聊: 【主动消息】【OBv11群聊:群号】消息内容\n")
sb.WriteString("- OBv11群聊@某人: 【主动消息】【OBv11群聊:群号@账号】消息内容\n")
sb.WriteString("\n可用的平台频道列表:\n")
for _, ch := range qqChannels {
if ch.Platform != "obv11" {
continue
}
label := ch.ChannelID
if ch.ChannelName != "" {
label = fmt.Sprintf("%s (%s)", ch.ChannelName, ch.ChannelID)
}
if ch.ChannelType == "private" {
sb.WriteString(fmt.Sprintf("- 私聊 %s\n", label))
} else {
sb.WriteString(fmt.Sprintf("- 群聊 %s\n", label))
}
}
sb.WriteString("\n注意事项:\n")
sb.WriteString("- 只给真正有互动过的用户/群聊发消息,不要群发骚扰\n")
sb.WriteString("- 私聊消息要简短自然,像朋友间的问候\n")
sb.WriteString("- 群聊消息要有公共价值,不要当成私聊\n")
sb.WriteString("- @某人时:确认这个人在群里有发言过,你的@内容是对他之前说的话的回应\n")
if triggerReason == "post_chat" {
sb.WriteString("- 重要:如果你在反思中想往平台群发消息,必须用【主动消息】【OBv11群聊:群号】格式,不要省略群号!\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.muLock()
defer t.muUnlock()
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
Content: content,
CreatedAt: time.Now(),
Consumed: false,
})
// Keep at most 10 recent thoughts.
if len(t.pendingThoughts) > 10 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
// Extract proactive message and optional platform target.
proactiveMsg, proactiveTarget := t.extractProactiveMessage(content)
// Prefer active session, fall back to admin main session.
pushSessionID := t.activeSessionID
if pushSessionID == "" {
pushSessionID = t.adminSessionID
}
pusher := t.messagePusher
platformPusher := t.platformMessagePusher
isPlatformMsg := proactiveTarget != nil
canPush := proactiveMsg != "" && ((!isPlatformMsg && pusher != nil) || (isPlatformMsg && platformPusher != nil))
if canPush {
// ProactiveGuard rate limiting (applies to both web and platform pushes).
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.lastProactiveTime(), urgency, "active")
logDecision(decision)
if !decision.ShouldSend {
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
t.setLastProactive(t.lastProactiveMsgTime)
t.proactiveGuard.RecordSend(time.Now())
}
} else if canPush {
gapSinceLast := time.Since(t.lastProactiveTime())
if gapSinceLast < 30*time.Minute {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送", gapSinceLast.Round(time.Second))
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
t.setLastProactive(t.lastProactiveMsgTime)
}
}
}
// Copy target for use after unlock (avoid race).
var targetCopy *ProactiveTarget
if proactiveTarget != nil {
copy := *proactiveTarget
targetCopy = &copy
}
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
// Async persist to 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)
}
}()
}
// Push proactive message.
if canPush {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 推送主动消息 panic 恢复: %v", r)
}
}()
if targetCopy != nil {
log.Printf("[后台思考] 推送平台主动消息: target=%s/%s user=%s group=%s msg=%s",
targetCopy.Platform, targetCopy.ChatType, targetCopy.UserID, targetCopy.GroupID, proactiveMsg)
platformPusher(*targetCopy, proactiveMsg)
if t.convStore != nil && pushSessionID != "" {
t.convStore.AddMessage(pushSessionID, model.LLMMessage{Role: model.RoleAssistant, Content: proactiveMsg})
}
} else {
log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg)
pusher(t.adminUserID, pushSessionID, proactiveMsg)
// 同步推送到 QQ 私聊
if platformPusher != nil {
t.mu.Lock()
channels := t.platformChannels
t.mu.Unlock()
for _, ch := range channels {
if ch.ChannelType == "direct" {
uid := strings.TrimPrefix(ch.ChannelID, "private_")
target := ProactiveTarget{Platform: ch.AdapterName, ChatType: "private", UserID: uid}
if target.Platform == "" { target.Platform = ch.Platform }
log.Printf("[后台思考] 同步推送主动消息到平台: %s/%s user=%s", target.Platform, target.ChatType, target.UserID)
platformPusher(target, proactiveMsg)
break
}
}
}
if t.convStore != nil && pushSessionID != "" {
t.convStore.AddMessage(pushSessionID, model.LLMMessage{Role: model.RoleAssistant, Content: proactiveMsg})
}
}
}()
}
}
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容。
// 返回空字符串表示没有主动消息。
//
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
// extractProactiveMessage extracts the 【主动消息】 marker and optional platform target from thinking content.
// Returns the message content and an optional ProactiveTarget for platform delivery.
// When target is nil, the message goes through the existing Web push path.
func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTarget) {
marker := "【主动消息】"
// Scan each line; only accept lines where the marker starts the line (ignoring leading whitespace).
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, marker) {
continue
}
// Check negation context: reject if prefix contains negation words.
markerIdx := strings.Index(line, marker)
prefix := strings.TrimSpace(line[:markerIdx])
if containsNegation(prefix) {
continue
}
// Extract everything after the marker.
raw := strings.TrimSpace(trimmed[len(marker):])
if raw == "" {
continue
}
// Parse optional platform target marker right after 【主动消息】.
msg := raw
var target *ProactiveTarget
// Try each registered platform format.
for platform, pf := range t.platformFormats {
if pf.compiled == nil {
continue
}
if loc := pf.compiled.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 {
m := pf.compiled.FindStringSubmatch(raw)
if len(m) == 4 {
chatType := "private"
if m[1] == pf.GroupKeyword {
chatType = "group"
}
// Resolve adapter name from registered channels.
channelID := m[2]
if chatType == "private" {
channelID = "private_" + m[2]
}
adapterName := platform // fallback to format key
t.muLock()
defer t.muUnlock()
for _, ch := range t.platformChannels {
if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID {
if ch.AdapterName != "" {
adapterName = ch.AdapterName
}
break
}
}
target = &ProactiveTarget{
Platform: adapterName,
ChatType: chatType,
UserID: m[2],
GroupID: m[2],
AtUserID: m[3],
}
if chatType == "private" {
target.GroupID = ""
}
}
msg = strings.TrimSpace(raw[loc[1]:])
if msg == "" {
continue
}
break
}
}
// Limit message length (200 chars max, keep it short).
runes := []rune(msg)
if len(runes) > 200 {
msg = string(runes[:200])
}
return msg, target
}
return "", nil
}
// 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
}
// muLock acquires t.mu with caller tracking.
func (t *Thinker) muLock() {
t.mu.Lock()
_, file, line, _ := runtime.Caller(1)
if idx := strings.LastIndex(file, "Cyrene/"); idx >= 0 {
file = file[idx+len("Cyrene/"):]
}
t.muLockedBy = fmt.Sprintf("%s:%d", file, line)
t.muLockedAt = time.Now()
}
// muUnlock releases t.mu and clears the tracking.
func (t *Thinker) muUnlock() {
t.muLockedBy = ""
t.mu.Unlock()
}
// ========== lock-free atomic accessors ==========
func (t *Thinker) lastUserTime() time.Time { return time.Unix(0, t.lastUserMsgNs.Load()) }
func (t *Thinker) setLastUser(ts time.Time) { t.lastUserMsgNs.Store(ts.UnixNano()) }
func (t *Thinker) lastThinkTimeAtomic() time.Time { return time.Unix(0, t.lastThinkNs.Load()) }
func (t *Thinker) setLastThink(ts time.Time) { t.lastThinkNs.Store(ts.UnixNano()) }
func (t *Thinker) thinkCount() int64 { return t.thinkCountAtomic.Add(0) }
func (t *Thinker) incThinkCount() int64 { return t.thinkCountAtomic.Add(1) }
func (t *Thinker) isUserOnline() bool { return t.userOnlineAtomic.Load() }
func (t *Thinker) setUserOnline(v bool) { t.userOnlineAtomic.Store(v) }
func (t *Thinker) lastProactiveTime() time.Time { return time.Unix(0, t.lastProactiveNs.Load()) }
func (t *Thinker) setLastProactive(ts time.Time) { t.lastProactiveNs.Store(ts.UnixNano()) }
// DeadlockDetected tries to acquire t.mu with a timeout. Returns true if the lock appears orphaned.
func (t *Thinker) DeadlockDetected(timeout time.Duration) bool {
done := make(chan struct{})
go func() {
t.muLock()
defer t.muUnlock()
close(done)
}()
select {
case <-done:
return false
case <-time.After(timeout):
return true
}
}
// LockHolder returns info about who currently holds t.mu (for debugging).
func (t *Thinker) LockHolder() (string, time.Time) {
return t.muLockedBy, t.muLockedAt
}
// StartDeadlockMonitor 启动死锁监控:定期检查 t.mu 是否有 orphaned lock
func (t *Thinker) StartDeadlockMonitor() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
done := make(chan struct{})
go func() {
t.muLock()
defer t.muUnlock()
close(done)
}()
select {
case <-done:
// lock is healthy
case <-time.After(15 * time.Second):
log.Printf("[deadlock-monitor] ⚠️ t.mu 超过15秒无法获取!可能死锁。锁持有者: %s, 持有时长: %v",
t.muLockedBy, time.Since(t.muLockedAt).Round(time.Second))
}
}
}()
}