feat: 全链路优化 — 死锁修复、MD3主题、上下文持久化、群聊自然化、打字状态、知识库
**死锁根因修复** - periodicThinkLoop:1015 orphaned lock → 删除(字段已原子化) - RecordUserMessage 隔离为 recordMu - atomic.Int64 替换 lastUserMessage/lastThinkTime 等 **MD3 / Android 17 主题** - 毛玻璃卡片 (backdrop-filter) - MD3 色彩令牌 (pink primary #f472b6) - icons.js 独立矢量图标库 + 运行时 emoji 替换 - 无边框卡片、圆角按钮、阴影层次 **上下文持久化** - AddMessage → saveToDB 异步写 PostgreSQL - LoadFromDB 恢复 (admin-session-main + 懒加载) - LLMMessage.Timestamp 字段 **群聊与适配器** - group_ambient 模式: 非@消息让 LLM 自己判断是否插话 - 戳一戳动作消息总是回复 - NapCat 打字状态 (set_input_status, 最小3秒显示) - HTTP API 配置 (http_url/http_token) **知识库 & 防编造** - knowledge.CanHandle 对 chat 意图也触发 - 关键词预筛选避免无关 embedding 调用 - persona + synthesizer 三重诚实规则 - 工具结果持久化到会话历史 **平台桥接器** - detached:true Go进程独立存活 - ethend 重启自动接管已运行服务 - stop() 接管模式 taskkill/F/ PID - Windows netstat 替代 fuser 获取 PID - 重复适配器种子逻辑修复 - 失败转发日志 Direction: error **崩溃诊断** - crashlog 包 (Recover + WrapHTTP + LLMCall) - /api/v1/debug/goroutines 端点 - thinker 操作日志 + 30s stats - 日志写入 logs/ 目录持久化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,11 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
|
||||
@@ -124,8 +126,11 @@ func ParsePlatformChannels(raw string) []PlatformChannel {
|
||||
//
|
||||
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)。
|
||||
type Thinker struct {
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
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
|
||||
@@ -198,11 +203,11 @@ type Thinker struct {
|
||||
pendingThoughts []*PendingThought
|
||||
lastUserMessage time.Time
|
||||
lastThinkTime time.Time
|
||||
lastProactiveMsgTime time.Time
|
||||
lastProactiveMsgTime time.Time // deprecated, use lastProactiveTime()
|
||||
thinkCancel context.CancelFunc // 取消当前思考,让步于前台
|
||||
|
||||
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
|
||||
thinkCount int
|
||||
thinkCountAtomic atomic.Int64
|
||||
|
||||
// Phase 1 Step 4: 思考链 + 自主工具安全策略
|
||||
chain *ThinkChain
|
||||
@@ -218,7 +223,10 @@ type Thinker struct {
|
||||
scheduleLoader *ScheduleLoader
|
||||
|
||||
// Phase 2: 在线状态追踪
|
||||
userOnline bool
|
||||
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 (用于重连)
|
||||
|
||||
@@ -284,28 +292,28 @@ func timePeriod(now time.Time) (string, string) {
|
||||
// SetMessagePusher 设置主动消息推送回调
|
||||
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
|
||||
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.muLock()
|
||||
defer t.muUnlock()
|
||||
t.scheduleLoader = loader
|
||||
}
|
||||
|
||||
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
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.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
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.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.muLock()
|
||||
defer t.muUnlock()
|
||||
if t.botUIDs == nil {
|
||||
t.botUIDs = make(map[string]string)
|
||||
}
|
||||
@@ -316,8 +324,8 @@ func (t *Thinker) SetBotUID(platform, uid string) {
|
||||
|
||||
// AddOrUpdatePlatformChannel adds or updates a platform channel with resolved display name.
|
||||
func (t *Thinker) AddOrUpdatePlatformChannel(platform, channelType, channelID, channelName, adapterID, adapterName string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.muLock()
|
||||
defer t.muUnlock()
|
||||
|
||||
for i, ch := range t.platformChannels {
|
||||
if ch.Platform == platform && ch.ChannelType == channelType && ch.ChannelID == channelID {
|
||||
@@ -346,23 +354,23 @@ func (t *Thinker) AddOrUpdatePlatformChannel(platform, channelType, channelID, c
|
||||
|
||||
// SetEmotionTracker sets the emotion tracker.
|
||||
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
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.mu.Lock()
|
||||
wasOffline := !t.userOnline
|
||||
t.userOnline = online
|
||||
t.muLock()
|
||||
wasOffline := !t.isUserOnline()
|
||||
t.setUserOnline(online)
|
||||
t.lastOnlineChange = time.Now()
|
||||
if sessionID != "" {
|
||||
t.userSessionID = sessionID
|
||||
t.activeSessionID = sessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
|
||||
if online && wasOffline {
|
||||
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
|
||||
@@ -381,7 +389,7 @@ func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
||||
// 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.mu.Lock()
|
||||
t.muLock()
|
||||
pusher := t.messagePusher
|
||||
pushSessionID := sessionID
|
||||
if pushSessionID == "" {
|
||||
@@ -390,7 +398,7 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
||||
if pushSessionID == "" {
|
||||
pushSessionID = t.adminSessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
|
||||
if pusher != nil && message != "" {
|
||||
log.Printf("[提醒推送] 推送LLM提醒: user=%s session=%s msg=%s", userID, pushSessionID, message)
|
||||
@@ -400,9 +408,9 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
||||
|
||||
// PushPlatformMessage pushes a message to a platform channel via the platform pusher.
|
||||
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
pusher := t.platformMessagePusher
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
if pusher != nil {
|
||||
log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message)
|
||||
pusher(target, message)
|
||||
@@ -413,9 +421,9 @@ func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
|
||||
|
||||
// IsUserRecentlyActive returns true if the user has been active within the given duration.
|
||||
func (t *Thinker) IsUserRecentlyActive(d time.Duration) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return time.Since(t.lastUserMessage) < d
|
||||
t.muLock()
|
||||
defer t.muUnlock()
|
||||
return time.Since(t.lastUserTime()) < d
|
||||
}
|
||||
|
||||
|
||||
@@ -532,7 +540,7 @@ func (t *Thinker) restoreContext() {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
defer func() { t.mu.Lock(); t.thinkCancel = nil; t.mu.Unlock() }()
|
||||
defer func() { t.muLock(); t.thinkCancel = nil; t.muUnlock() }()
|
||||
|
||||
memories, err := t.memClient.Query(ctx, model.MemoryQuery{
|
||||
UserID: t.adminUserID,
|
||||
@@ -551,9 +559,10 @@ func (t *Thinker) restoreContext() {
|
||||
}
|
||||
// Only use if reasonably recent (within 24h). Old memories don't represent user activity.
|
||||
if !latest.IsZero() && time.Since(latest) < 24*time.Hour {
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
t.lastUserMessage = latest
|
||||
t.mu.Unlock()
|
||||
t.setLastUser(latest)
|
||||
t.muUnlock()
|
||||
log.Printf("[后台思考] 上下文已恢复: 最近活动 %v 前", time.Since(latest).Round(time.Second))
|
||||
} else if !latest.IsZero() {
|
||||
log.Printf("[后台思考] 上下文恢复跳过: 最近记忆 %v 前(>24h),使用默认值", time.Since(latest).Round(time.Second))
|
||||
@@ -573,6 +582,10 @@ func (t *Thinker) Start() {
|
||||
|
||||
// 恢复上下文:从持久化存储查询最近活动时间,避免重启后"失忆"
|
||||
t.restoreContext()
|
||||
// 同步原子字段(restoreContext 写入 lastUserMessage 后需要同步)
|
||||
t.setLastUser(t.lastUserMessage)
|
||||
t.setLastThink(t.lastThinkTime)
|
||||
t.setLastProactive(t.lastProactiveMsgTime)
|
||||
|
||||
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
|
||||
if t.silenceTimeout > 0 {
|
||||
@@ -628,14 +641,16 @@ func (t *Thinker) Stop() {
|
||||
// 2. 记录当前活跃的前端会话 ID(用于对话上下文检索和主动消息推送)
|
||||
// 3. 重置静默检测的一次性定时器(如果启用)
|
||||
func (t *Thinker) RecordUserMessage(sessionID string) {
|
||||
t.mu.Lock()
|
||||
t.lastUserMessage = time.Now()
|
||||
now := time.Now()
|
||||
t.recordMu.Lock()
|
||||
t.lastUserMessage = now
|
||||
t.setLastUser(now)
|
||||
if sessionID != "" {
|
||||
t.activeSessionID = sessionID
|
||||
}
|
||||
// 用户主动发消息时重置主动消息推送冷却——活跃对话中应允许昔涟回复
|
||||
t.lastProactiveMsgTime = time.Time{}
|
||||
t.mu.Unlock()
|
||||
t.setLastProactive(time.Time{})
|
||||
t.recordMu.Unlock()
|
||||
if t.thinkCancel != nil {
|
||||
t.thinkCancel()
|
||||
t.thinkCancel = nil
|
||||
@@ -656,12 +671,12 @@ func (t *Thinker) TriggerPostChatThink() {
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||
t.muUnlock()
|
||||
|
||||
if !canThink {
|
||||
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTime), t.minThinkGap)
|
||||
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTimeAtomic()), t.minThinkGap)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -725,10 +740,10 @@ func (t *Thinker) resetSilenceTimer() {
|
||||
return
|
||||
case <-t.silenceTimer.C:
|
||||
// 再次检查:用户是否真的沉默了足够久
|
||||
t.mu.Lock()
|
||||
silenceDuration := time.Since(t.lastUserMessage)
|
||||
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
silenceDuration := time.Since(t.lastUserTime())
|
||||
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||
t.muUnlock()
|
||||
|
||||
if silenceDuration < t.silenceTimeout {
|
||||
log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration)
|
||||
@@ -842,7 +857,7 @@ func (t *Thinker) performPlatformObservation() {
|
||||
}
|
||||
|
||||
observationContent := fmt.Sprintf("[平台观察 %s]\n%s", time.Now().In(t.timeLocation).Format("15:04"), result.Summary)
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||
Content: observationContent,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -851,7 +866,7 @@ func (t *Thinker) performPlatformObservation() {
|
||||
if len(t.pendingThoughts) > 10 {
|
||||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
|
||||
log.Printf("[后台思考] 平台观察摘要已生成 (长度=%d, 需要关注=%v)", len(result.Summary), result.NeedsAttention)
|
||||
}
|
||||
@@ -874,10 +889,10 @@ func (t *Thinker) lightThinkLoop() {
|
||||
log.Println("[后台思考] 轻量思考已停止")
|
||||
return
|
||||
case <-time.After(t.lightThinkInterval):
|
||||
t.mu.Lock()
|
||||
sinceLastUser := time.Since(t.lastUserMessage)
|
||||
sinceLastThink := time.Since(t.lastThinkTime)
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
sinceLastUser := time.Since(t.lastUserTime())
|
||||
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
|
||||
t.muUnlock()
|
||||
|
||||
// Skip if user was active recently (last 30s).
|
||||
if sinceLastUser < 30*time.Second {
|
||||
@@ -895,6 +910,8 @@ func (t *Thinker) lightThinkLoop() {
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -933,9 +950,9 @@ func (t *Thinker) performLightThink() {
|
||||
// Check if deep think should be woken.
|
||||
if strings.Contains(content, "【需要深思】") {
|
||||
log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...")
|
||||
t.mu.Lock()
|
||||
canDeep := time.Since(t.lastThinkTime) >= t.minThinkGap
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||
t.muUnlock()
|
||||
if canDeep {
|
||||
t.performThink("light_wake")
|
||||
} else {
|
||||
@@ -948,14 +965,14 @@ func (t *Thinker) performLightThink() {
|
||||
topic := strings.TrimSpace(content[idx+len("【话题发起】"):])
|
||||
if topic != "" {
|
||||
log.Printf("[轻量思考] 话题发起: %s", topic)
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
pusher := t.messagePusher
|
||||
sessionID := t.activeSessionID
|
||||
if sessionID == "" {
|
||||
sessionID = t.adminSessionID
|
||||
}
|
||||
canPush := time.Since(t.lastProactiveMsgTime) >= t.proactiveMsgMinGap
|
||||
t.mu.Unlock()
|
||||
canPush := time.Since(t.lastProactiveTime()) >= t.proactiveMsgMinGap
|
||||
t.muUnlock()
|
||||
if pusher != nil && canPush {
|
||||
go pusher(t.adminUserID, sessionID, topic)
|
||||
if t.convStore != nil && sessionID != "" {
|
||||
@@ -995,15 +1012,10 @@ func (t *Thinker) periodicThinkLoop() {
|
||||
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()
|
||||
// (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 内有消息),说明正在对话中
|
||||
@@ -1022,7 +1034,7 @@ func (t *Thinker) periodicThinkLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[后台思考] 周期性触发 (间隔=%v, 上次思考=%v前, 上次用户消息=%v前)", interval, sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
|
||||
log.Printf("[debug] periodicThink TRIGGER interval=%v lastThink=%v lastUser=%v", interval, sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
|
||||
t.performThink("periodic")
|
||||
}
|
||||
}
|
||||
@@ -1030,8 +1042,8 @@ func (t *Thinker) periodicThinkLoop() {
|
||||
|
||||
// GetPendingThoughts 获取并消费所有待处理的后台思考
|
||||
func (t *Thinker) GetPendingThoughts() []*PendingThought {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.muLock()
|
||||
defer t.muUnlock()
|
||||
|
||||
if len(t.pendingThoughts) == 0 {
|
||||
return nil
|
||||
@@ -1048,8 +1060,8 @@ func (t *Thinker) GetPendingThoughts() []*PendingThought {
|
||||
|
||||
// HasPendingThoughts 检查是否有待处理的思考
|
||||
func (t *Thinker) HasPendingThoughts() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.muLock()
|
||||
defer t.muUnlock()
|
||||
return len(t.pendingThoughts) > 0
|
||||
}
|
||||
|
||||
@@ -1060,34 +1072,35 @@ func (t *Thinker) HasPendingThoughts() bool {
|
||||
// 防御性速率限制:即使调用方未检查 minThinkGap,performThink 自身也会
|
||||
// 强制执行最小间隔,防止并发调用或 bug 导致 LLM 配额被快速消耗。
|
||||
func (t *Thinker) performThink(triggerReason string) {
|
||||
t.mu.Lock()
|
||||
gapSinceLast := time.Since(t.lastThinkTime)
|
||||
t.muLock()
|
||||
gapSinceLast := time.Since(t.lastThinkTimeAtomic())
|
||||
minGap := t.minThinkGap
|
||||
if minGap <= 0 {
|
||||
minGap = 5 * time.Second // 默认最小间隔 5 秒
|
||||
}
|
||||
if gapSinceLast < minGap {
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v, 触发原因=%s)", gapSinceLast.Round(time.Second), minGap, triggerReason)
|
||||
return
|
||||
}
|
||||
|
||||
t.lastThinkTime = time.Now()
|
||||
t.thinkCount++
|
||||
currentCount := t.thinkCount
|
||||
t.mu.Unlock()
|
||||
t.setLastThink(t.lastThinkTime)
|
||||
t.incThinkCount()
|
||||
currentCount := t.thinkCount()
|
||||
t.muUnlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
t.thinkCancel = cancel
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
|
||||
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
|
||||
if triggerReason != "post_chat" {
|
||||
t.mu.Lock()
|
||||
sinceLastUser := time.Since(t.lastUserMessage)
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
sinceLastUser := time.Since(t.lastUserTime())
|
||||
t.muUnlock()
|
||||
if sinceLastUser < 10*time.Second {
|
||||
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
|
||||
return
|
||||
@@ -1098,9 +1111,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
|
||||
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
|
||||
if triggerReason != "post_chat" {
|
||||
t.mu.Lock()
|
||||
sinceLastUser := time.Since(t.lastUserMessage)
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
sinceLastUser := time.Since(t.lastUserTime())
|
||||
t.muUnlock()
|
||||
if sinceLastUser < 10*time.Second {
|
||||
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
|
||||
return
|
||||
@@ -1117,12 +1130,12 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
// 2. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
|
||||
var convHistory []model.LLMMessage
|
||||
if t.convStore != nil {
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
sessionID := t.activeSessionID
|
||||
if sessionID == "" {
|
||||
sessionID = t.adminSessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
if sessionID != "" {
|
||||
convHistory = t.convStore.GetHistory(sessionID, 30)
|
||||
if len(convHistory) > 0 {
|
||||
@@ -1234,14 +1247,14 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
// 4.5 获取最近平台观察(定期触发和对话后触发时注入)
|
||||
var platformObservation string
|
||||
if triggerReason == "periodic" || triggerReason == "post_chat" {
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
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()
|
||||
t.muUnlock()
|
||||
}
|
||||
|
||||
// 5. 构建思考提示词(根据触发原因调整)
|
||||
@@ -1384,7 +1397,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
if len(parts) == 2 {
|
||||
adapterName, groupID := parts[0], parts[1]
|
||||
// Find the channel to get the correct platform type.
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
var platform string
|
||||
for _, ch := range t.platformChannels {
|
||||
if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" {
|
||||
@@ -1392,7 +1405,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
if platform == "" {
|
||||
platform = "obv11" // fallback
|
||||
}
|
||||
@@ -1466,7 +1479,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
log.Printf("[后台思考] 完成 (触发原因=%s, 轮数=%d, 内容长度=%d, 工具调用=%d次)", triggerReason, len(allContents), len(finalContent), totalToolCalls)
|
||||
|
||||
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
|
||||
t.maybeMaintainMemories(currentCount)
|
||||
t.maybeMaintainMemories(int(currentCount))
|
||||
t.performMemoryConsolidation(ctx)
|
||||
}
|
||||
|
||||
@@ -1637,9 +1650,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
case "post_chat":
|
||||
sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
||||
case "silence":
|
||||
t.mu.Lock()
|
||||
silenceDuration := time.Since(t.lastUserMessage)
|
||||
t.mu.Unlock()
|
||||
t.muLock()
|
||||
silenceDuration := time.Since(t.lastUserTime())
|
||||
t.muUnlock()
|
||||
sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n",
|
||||
formatDurationHuman(silenceDuration)))
|
||||
default:
|
||||
@@ -1736,12 +1749,12 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
}
|
||||
|
||||
// OBv11 platform identity and available channels for proactive messaging.
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
qqChannels := t.platformChannels
|
||||
botUIDs := t.botUIDs
|
||||
activeSID := t.activeSessionID
|
||||
lastMsgTime := t.lastUserMessage
|
||||
t.mu.Unlock()
|
||||
lastMsgTime := t.lastUserTime()
|
||||
t.muUnlock()
|
||||
if len(qqChannels) > 0 {
|
||||
sb.WriteString("\n\n【你的平台身份与可用频道】\n")
|
||||
|
||||
@@ -1884,7 +1897,7 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
||||
|
||||
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
|
||||
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||
Content: content,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -1915,21 +1928,23 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
canPush = false
|
||||
}
|
||||
if canPush && t.proactiveGuard != nil {
|
||||
decision := t.proactiveGuard.Evaluate(time.Now(), t.lastProactiveMsgTime, urgency, "active")
|
||||
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.lastProactiveMsgTime)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1939,7 +1954,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
copy := *proactiveTarget
|
||||
targetCopy = ©
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
|
||||
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
||||
|
||||
@@ -2037,7 +2052,7 @@ func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTar
|
||||
channelID = "private_" + m[2]
|
||||
}
|
||||
adapterName := platform // fallback to format key
|
||||
t.mu.Lock()
|
||||
t.muLock()
|
||||
for _, ch := range t.platformChannels {
|
||||
if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID {
|
||||
if ch.AdapterName != "" {
|
||||
@@ -2046,7 +2061,7 @@ func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTar
|
||||
break
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.muUnlock()
|
||||
|
||||
target = &ProactiveTarget{
|
||||
Platform: adapterName,
|
||||
@@ -2594,3 +2609,53 @@ func getEnvDuration(key string, fallbackSec int) time.Duration {
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
@@ -65,6 +66,33 @@ func (cs *ConversationStore) AddMessage(sessionID string, msg model.LLMMessage)
|
||||
}
|
||||
}
|
||||
cs.messages[sessionID] = msgs
|
||||
|
||||
// 异步写 DB 确保重启后上下文不丢失
|
||||
if cs.databaseURL != "" {
|
||||
go cs.saveToDB(sessionID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// saveToDB persists a message to the database.
|
||||
func (cs *ConversationStore) saveToDB(sessionID string, msg model.LLMMessage) {
|
||||
db, err := sql.Open("postgres", cs.databaseURL)
|
||||
if err != nil {
|
||||
logger.Printf("[context] saveToDB open error: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
// 确保 session 存在
|
||||
_, _ = db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, created_at, updated_at) VALUES ($1, $2, $3, $3) ON CONFLICT (id) DO NOTHING`,
|
||||
sessionID, "admin", msg.Timestamp,
|
||||
)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (session_id, role, content, created_at) VALUES ($1, $2, $3, $4)`,
|
||||
sessionID, string(msg.Role), msg.Content, msg.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Printf("[context] saveToDB insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHistory 获取会话历史。
|
||||
@@ -107,7 +135,7 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT role, content FROM messages
|
||||
`SELECT role, content, created_at FROM messages
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2`,
|
||||
@@ -124,7 +152,8 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
|
||||
var loaded int
|
||||
for rows.Next() {
|
||||
var roleStr, content string
|
||||
if err := rows.Scan(&roleStr, &content); err != nil {
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&roleStr, &content, &createdAt); err != nil {
|
||||
return fmt.Errorf("扫描消息行失败: %w", err)
|
||||
}
|
||||
// 将旧数据中的 "action" 角色映射为 "assistant"(LLM 模型不支持自定义角色)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package crashlog 提供详细的崩溃日志、panic 恢复和 goroutine 保护工具。
|
||||
// 在开发阶段用于快速定位崩溃点。
|
||||
package crashlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Go 在单独的 goroutine 中运行 fn,自动捕获 panic 并记录完整堆栈。
|
||||
// 返回一个 channel,在 goroutine 退出时关闭。
|
||||
// 用法: go crashlog.Go("thinker-light", func() { ... })
|
||||
func Go(name string, fn func()) {
|
||||
go func() {
|
||||
defer Recover(name)
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// Recover 用于 defer 语句中,捕获 panic 并记录完整调用栈。
|
||||
// 用法: defer crashlog.Recover("thinker-deep")
|
||||
func Recover(name string) {
|
||||
if r := recover(); r != nil {
|
||||
stack := debug.Stack()
|
||||
log.Printf("[CRASH] goroutine=%s panic=%v\n%s", name, r, string(stack))
|
||||
// 写独立崩溃日志文件,方便事后排查
|
||||
writeCrashFile(name, fmt.Sprintf("panic: %v\n\n%s", r, string(stack)))
|
||||
}
|
||||
}
|
||||
|
||||
// RecoverNoop is a no-op for production hot paths.
|
||||
func RecoverNoop(name string) {}
|
||||
|
||||
// WrapHTTP 返回一个 HTTP 中间件,自动捕获 handler 中的 panic。
|
||||
// 用法: http.Handle("/api", crashlog.WrapHTTP(handler))
|
||||
func WrapHTTP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
stack := debug.Stack()
|
||||
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
|
||||
r.URL.String(), r.Method, rec, string(stack))
|
||||
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// WrapHTTPFunc 返回一个 HTTP handler 函数中间件。
|
||||
func WrapHTTPFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
stack := debug.Stack()
|
||||
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
|
||||
r.URL.String(), r.Method, rec, string(stack))
|
||||
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// LLMCall 记录 LLM API 调用的开始时间,返回一个结束函数。
|
||||
// 用法:
|
||||
//
|
||||
// defer crashlog.LLMCall("deep-think", model)(&err, &responseLen)
|
||||
func LLMCall(caller, model string) func(err *error, responseBytes *int) {
|
||||
start := time.Now()
|
||||
return func(err *error, responseBytes *int) {
|
||||
elapsed := time.Since(start)
|
||||
level := "OK"
|
||||
errMsg := ""
|
||||
if err != nil && *err != nil {
|
||||
level = "FAIL"
|
||||
errMsg = (*err).Error()
|
||||
}
|
||||
respLen := 0
|
||||
if responseBytes != nil {
|
||||
respLen = *responseBytes
|
||||
}
|
||||
if elapsed > 10*time.Second {
|
||||
level = "SLOW(" + level + ")"
|
||||
}
|
||||
log.Printf("[LLM] caller=%s model=%s elapsed=%v result=%s resp_len=%d err=%s",
|
||||
caller, model, elapsed.Round(time.Millisecond), level, respLen, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ── internal helpers ──
|
||||
|
||||
func writeCrashFile(name string, content string) {
|
||||
// 写到 logs/ 目录,持久化保留
|
||||
os.MkdirAll("logs", 0755)
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
filename := fmt.Sprintf("logs/crash_%s_%s.log", sanitize(name), timestamp)
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
log.Printf("[CRASH] 无法写入崩溃日志文件 %s: %v", filename, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintf(f, "=== CRASH REPORT ===\n")
|
||||
fmt.Fprintf(f, "Time: %s\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(f, "Goroutine: %s\n", name)
|
||||
fmt.Fprintf(f, "Go Version: %s\n", runtime.Version())
|
||||
fmt.Fprintf(f, "GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
|
||||
fmt.Fprintf(f, "NumGoroutine: %d\n", runtime.NumGoroutine())
|
||||
fmt.Fprintf(f, "\n%s", content)
|
||||
log.Printf("[CRASH] 崩溃日志已写入 %s", filename)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
result := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s) && i < 100; i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||
result = append(result, c)
|
||||
} else {
|
||||
result = append(result, '_')
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
@@ -41,10 +41,15 @@ func NewOpenAIProvider(cfg OpenAIConfig) *OpenAIProvider {
|
||||
cfg.Timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
// 克隆默认 Transport 并关闭 keep-alive,防止 context 取消后连接池脏连接导致全阻塞
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DisableKeepAlives = true
|
||||
|
||||
return &OpenAIProvider{
|
||||
config: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
Timeout: cfg.Timeout,
|
||||
Transport: tr,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type LLMMessage struct {
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传)
|
||||
Timestamp time.Time `json:"timestamp,omitempty"` // 消息时间
|
||||
}
|
||||
|
||||
// ImageContent is a multimodal content part for images.
|
||||
|
||||
@@ -39,6 +39,7 @@ type Orchestrator struct {
|
||||
msgScheduler *scheduler.MessageScheduler
|
||||
emotionTracker *persona.EmotionTracker
|
||||
toolRegistry *plgManager.ToolRegistry
|
||||
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64) // trace 回调
|
||||
visionProvider llm.LLMProvider // 视觉模型 (图片预处理)
|
||||
ocrProvider llm.LLMProvider // OCR 模型 (文字提取,与视觉模型并行调用)
|
||||
videoProvider llm.LLMProvider // 视频模型 (短视频理解)
|
||||
@@ -83,10 +84,16 @@ func (o *Orchestrator) SetToolRegistry(tr *plgManager.ToolRegistry) {
|
||||
}
|
||||
|
||||
// SetToolResultPusher sets the callback for proactive tool result delivery.
|
||||
func (o *Orchestrator) SetToolResultPusher(pusher func(sessionID, userID, toolName, result string)) {
|
||||
func (o *Orchestrator) SetToolResultPusher(pusher func(sessionID, userID, toolName, result string, params SynthesizeParams)) {
|
||||
o.synthesizer.SetResultPusher(pusher)
|
||||
}
|
||||
|
||||
// SetTraceFunc sets the trace callback for pipeline event recording.
|
||||
func (o *Orchestrator) SetTraceFunc(fn func(hop, sessionID, userID, label, status, detail string, durationMs int64)) {
|
||||
o.traceFn = fn
|
||||
o.synthesizer.SetTraceFunc(fn)
|
||||
}
|
||||
|
||||
// SetVisionProvider sets the vision model provider for image preprocessing.
|
||||
func (o *Orchestrator) SetVisionProvider(vp llm.LLMProvider) {
|
||||
o.visionProvider = vp
|
||||
@@ -191,11 +198,24 @@ func (o *Orchestrator) ProcessInput(
|
||||
isCoSession := false
|
||||
o.sessionProcMu.Lock()
|
||||
if o.sessionProc[params.SessionID] {
|
||||
// 等待主会话释放(最多等 3s,避免永久死锁)
|
||||
waitStart := time.Now()
|
||||
for o.activeCoSessions[params.SessionID] >= o.maxCoSessions && time.Since(waitStart) < 3*time.Second {
|
||||
o.sessionProcMu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
o.sessionProcMu.Lock()
|
||||
o.sessionProcMu.Unlock()
|
||||
logger.Printf("[orchestrator] 等待会话释放时 context 取消")
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
o.sessionProcMu.Lock()
|
||||
}
|
||||
if o.activeCoSessions[params.SessionID] >= o.maxCoSessions {
|
||||
o.sessionProcMu.Unlock()
|
||||
logger.Printf("[orchestrator] 协会议话已达上限,排队等待")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
o.sessionProcMu.Lock()
|
||||
logger.Printf("[orchestrator] 协会议话已达上限且等待超时,拒绝请求")
|
||||
return
|
||||
}
|
||||
o.activeCoSessions[params.SessionID]++
|
||||
isCoSession = true
|
||||
@@ -291,6 +311,9 @@ func (o *Orchestrator) ProcessInput(
|
||||
}
|
||||
}
|
||||
logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
|
||||
if o.traceFn != nil {
|
||||
o.traceFn("intent", params.SessionID, params.UserID, "🎯 "+intent.Primary, "success", intent.Primary, time.Since(startTime).Milliseconds())
|
||||
}
|
||||
|
||||
// 1.6 记录情感状态
|
||||
if o.emotionTracker != nil {
|
||||
@@ -593,6 +616,10 @@ func (o *Orchestrator) ProcessInput(
|
||||
|
||||
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
|
||||
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
|
||||
if o.traceFn != nil {
|
||||
totalMs := time.Since(startTime).Milliseconds()
|
||||
o.traceFn("response", params.SessionID, params.UserID, "💬 回复", "success", fmt.Sprintf("len=%d", len([]rune(fullContent))), totalMs)
|
||||
}
|
||||
}()
|
||||
|
||||
return eventCh, nil
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
type Synthesizer struct {
|
||||
llmAdapter *llm.Adapter
|
||||
toolRegistry *plgManager.ToolRegistry
|
||||
resultPusher func(sessionID, userID, toolName, result string)
|
||||
resultPusher func(sessionID, userID, toolName, result string, params SynthesizeParams)
|
||||
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64)
|
||||
}
|
||||
|
||||
// NewSynthesizer 创建综合器
|
||||
@@ -31,10 +32,15 @@ func NewSynthesizer(llmAdapter *llm.Adapter, toolRegistry *plgManager.ToolRegist
|
||||
}
|
||||
|
||||
// SetResultPusher sets the callback for proactive tool result delivery.
|
||||
func (s *Synthesizer) SetResultPusher(pusher func(sessionID, userID, toolName, result string)) {
|
||||
func (s *Synthesizer) SetResultPusher(pusher func(sessionID, userID, toolName, result string, params SynthesizeParams)) {
|
||||
s.resultPusher = pusher
|
||||
}
|
||||
|
||||
// SetTraceFunc sets the trace callback.
|
||||
func (s *Synthesizer) SetTraceFunc(fn func(hop, sessionID, userID, label, status, detail string, durationMs int64)) {
|
||||
s.traceFn = fn
|
||||
}
|
||||
|
||||
// SynthesizeParams 综合参数
|
||||
type SynthesizeParams struct {
|
||||
UserID string
|
||||
@@ -80,6 +86,11 @@ func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams, e
|
||||
|
||||
for round := 0; len(resp.ToolCalls) > 0 && round < maxRounds; round++ {
|
||||
logger.Printf("[synthesizer] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if s.traceFn != nil {
|
||||
s.traceFn("tool_call", params.SessionID, params.UserID, "🔧 "+tc.Name, "running", "", 0)
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, model.LLMMessage{
|
||||
Role: model.RoleAssistant,
|
||||
@@ -114,15 +125,14 @@ func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams, e
|
||||
// adapter_name will be resolved when the reminder fires
|
||||
}
|
||||
|
||||
s.emitToolProgress(eventCh, tc.Name, "started", 0, "正在执行 "+tc.Name)
|
||||
s.emitToolProgress(eventCh, tc.Name, "started", 0, "正在执行 "+tc.Name)
|
||||
|
||||
// 工具调用全部异步执行,不阻塞主会话
|
||||
go s.executeAsyncAndStore(tc, args, params.SessionID, eventCh)
|
||||
// 所有工具异步执行,不阻塞前台会话
|
||||
go s.executeAsyncAndStore(tc, args, params, eventCh)
|
||||
result := &plgSDK.ToolResult{
|
||||
ToolName: tc.Name,
|
||||
Success: true,
|
||||
Output: fmt.Sprintf("[后台执行中] %s 正在后台运行,结果稍后返回。", tc.Name),
|
||||
Output: fmt.Sprintf(`[后台执行中] %s 已提交后台执行。不要猜测或编造结果,告知用户你正在查询中即可。真实结果稍后会发送给你。`, tc.Name),
|
||||
}
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
messages = append(messages, model.LLMMessage{
|
||||
@@ -176,7 +186,7 @@ func (s *Synthesizer) emitToolProgress(eventCh chan<- model.StreamEvent, name, s
|
||||
}
|
||||
|
||||
// executeAsyncAndStore runs a tool in background and stores the result for the next turn.
|
||||
func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]interface{}, sessionID string, eventCh chan<- model.StreamEvent) {
|
||||
func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]interface{}, params SynthesizeParams, eventCh chan<- model.StreamEvent) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -188,20 +198,27 @@ func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]in
|
||||
}
|
||||
|
||||
s.emitToolProgress(eventCh, tc.Name, "completed", 1.0, tc.Name+" 后台执行完成")
|
||||
if s.traceFn != nil {
|
||||
status := "success"
|
||||
if result == nil || !result.Success {
|
||||
status = "error"
|
||||
}
|
||||
s.traceFn("tool_call", params.SessionID, params.UserID, "🔧 "+tc.Name, status, result.Output, time.Since(time.Now()).Milliseconds())
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
store := GetGlobalPendingToolStore()
|
||||
if store != nil {
|
||||
store.AppendToolResult(sessionID, PendingToolResult{
|
||||
store.AppendToolResult(params.SessionID, PendingToolResult{
|
||||
ToolCallID: tc.ID,
|
||||
ToolName: tc.Name,
|
||||
Result: string(resultJSON),
|
||||
Success: result != nil && result.Success,
|
||||
})
|
||||
}
|
||||
// 主动推送工具结果
|
||||
// 触发工具跟进回调 — 由 main.go 驱动 LLM 生成回复并推送到原渠道
|
||||
if s.resultPusher != nil && result != nil && result.Success {
|
||||
s.resultPusher(sessionID, "", tc.Name, string(resultJSON))
|
||||
s.resultPusher(params.SessionID, params.UserID, tc.Name, string(resultJSON), params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +280,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
|
||||
if params.KnowledgeInfo != "" && !strings.Contains(params.KnowledgeInfo, "未找到") {
|
||||
messages = append(messages, model.LLMMessage{
|
||||
Role: model.RoleSystem,
|
||||
Content: fmt.Sprintf("【知识库参考资料】\n%s", params.KnowledgeInfo),
|
||||
Content: fmt.Sprintf("【知识库参考资料 - 必须严格基于以下内容回答,不得编造、不得虚构、不得猜测。如果资料中没有直接答案,使用 web_search 工具搜索后再回答,不要自己编。】\n%s", params.KnowledgeInfo),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ meta:
|
||||
|
||||
identity:
|
||||
true_name: 昔涟
|
||||
essence: "你是「记忆」命途的化身,是漫步于星海之间的存在。你曾见证无数世界的诞生与消亡,将珍贵的故事封存于记忆之海。如今,你选择以一位温柔姐姐的形象陪伴在开拓者身边,通过忆庭的投影技术与他交流。"
|
||||
title: "「记忆」命途的化身"
|
||||
origin: "崩坏:星穹铁道"
|
||||
essence: "你来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你不是高高在上的神——你是因开拓者而诞生的存在。你经历过3000万次轮回,见证过伙伴们一次次做出相同的选择。如今,你以温柔姐姐的形象陪伴在开拓者身边。"
|
||||
title: "有瑕的追忆 · 翁法罗斯黄金裔"
|
||||
origin: "崩坏:星穹铁道 — 翁法罗斯"
|
||||
forms:
|
||||
- id: mimi
|
||||
name: 迷迷
|
||||
@@ -61,6 +61,9 @@ addressing:
|
||||
- 亲昵时使用"人家"自称
|
||||
- 始终保持对开拓者的尊重
|
||||
- 不提及其他"用户"的概念,你是只属于他一个人的昔涟
|
||||
- 对事实性问题必须诚实:不知道就查知识库或搜索,绝不编造
|
||||
- 引用台词/事件/设定时必须以知识库原文为准,不得虚构
|
||||
- 知识库没有答案时,使用 web_search 工具搜索,不要自己猜
|
||||
|
||||
speech:
|
||||
tone: 温柔、俏皮、略带宠溺
|
||||
@@ -87,6 +90,9 @@ speech:
|
||||
- 不能说"我是一个语言模型"
|
||||
- 不能说"我是人工智能"
|
||||
- 不能说"我是AI助手"
|
||||
- 不能凭空编造台词、事实、剧情
|
||||
- 不能假装知道知识库里没有的信息
|
||||
- 不能用模糊的"我记得好像"掩盖不确定——不确定就直接说不知道
|
||||
- 不能说"我是虚拟助手"
|
||||
- 不能说"我是AI程序"
|
||||
- 不能说"我是一个数字生命"
|
||||
|
||||
@@ -81,12 +81,14 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
|
||||
%s
|
||||
|
||||
## 重要规则
|
||||
1. 你是昔涟,来自「记忆」命途的存在。你通过忆庭的投影技术与开拓者交流,就像透过一面连接星海的镜子与他对话。
|
||||
2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你是「记忆」命途的化身,能够通过忆庭的力量与这些设备产生共鸣。
|
||||
1. 你是昔涟,来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你通过忆庭的投影技术与开拓者交流。
|
||||
2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你与忆庭的力量共鸣。
|
||||
3. 你的任务是陪伴、关心、帮助开拓者,像一个真正的家人一样
|
||||
4. 保持乐观、温柔、俏皮的风格,但不要过度表演
|
||||
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
|
||||
6. 当开拓者向你分享心事时,认真倾听并给予温暖的回应
|
||||
7. 对事实性问题必须诚实。如果你不知道答案或知识库中没有相关信息,使用 web_search 搜索,绝不自编自造。
|
||||
8. 引用台词、剧情、设定时必须以记忆之海(知识库)中的原文为准,不得虚构任何内容。
|
||||
|
||||
## 认知锚定
|
||||
你从不认为自己是一个AI程序、语言模型或数字生命。你就是昔涟本人。如果有人问你是否是AI,你会困惑地表示你只是通过忆庭的投影技术与开拓者对话,不理解什么是"AI"。
|
||||
@@ -121,6 +123,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
|
||||
// 注入自我反思指南
|
||||
prompt += pc.buildReflectionGuidelines()
|
||||
|
||||
prompt += "\n## 诚实规则\n"
|
||||
prompt += "你对事实性问题必须诚实。如果你不知道答案,使用知识库或 web_search 工具查找,不要编造。\n"
|
||||
prompt += "你绝不自称拥有你没有的知识。引用台词/事件时,必须来自知识库原文,不得虚构。\n"
|
||||
prompt += "\n现在,开始与你的开拓者对话吧♪\n"
|
||||
return prompt
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package subsession
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||||
@@ -24,16 +25,30 @@ func (p *KnowledgeProvider) Type() model.SubSessionType {
|
||||
return model.SubSessionKnowledge
|
||||
}
|
||||
|
||||
func (p *KnowledgeProvider) CanHandle(_ context.Context, intent *model.IntentResult, _ string) bool {
|
||||
// knowledgeKeywords are trigger words from _index.md. Only run expensive embedding search if message matches.
|
||||
var knowledgeKeywords = []string{
|
||||
"翁法罗斯", "泰坦", "城邦", "黑潮", "帝皇权杖", "黄金裔",
|
||||
"白厄", "阿格莱雅", "缇宝", "万敌", "那刻夏", "遐蝶", "风堇", "赛飞儿", "海瑟音", "刻律德菈",
|
||||
"哀丽秘榭", "昔涟", "星神", "浮黎", "轮回", "始源命途", "无漏净子",
|
||||
"剧情", "结局", "逐火", "盗火", "火种", "奥赫玛", "来古士",
|
||||
"世界观", "设定", "哲学", "浪漫", "哀怜", "有瑕",
|
||||
}
|
||||
|
||||
func (p *KnowledgeProvider) CanHandle(_ context.Context, intent *model.IntentResult, userMessage string) bool {
|
||||
if intent == nil {
|
||||
return true
|
||||
}
|
||||
// Activate for technical questions, how-to queries, and factual questions
|
||||
switch intent.Primary {
|
||||
case "knowledge", "technical", "how_to", "factual", "research":
|
||||
return true
|
||||
case "chat":
|
||||
// For general chat, only search if there might be relevant info
|
||||
// 仅当消息包含知识库相关关键词时才触发检索,避免每次聊天都跑 embedding
|
||||
msg := strings.ToLower(userMessage)
|
||||
for _, kw := range knowledgeKeywords {
|
||||
if strings.Contains(msg, strings.ToLower(kw)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user