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:
2026-06-28 11:29:29 +08:00
parent 0d6970a2d3
commit fd44b15d81
23 changed files with 1447 additions and 254 deletions
+124 -9
View File
@@ -9,6 +9,7 @@ import (
"log"
"net/http"
"os"
"runtime"
"os/signal"
"path/filepath"
"strconv"
@@ -20,6 +21,7 @@ import (
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
aiConfig "git.yeij.top/AskaEth/Cyrene/ai-core/internal/config"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/crashlog"
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/host"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
@@ -448,15 +450,78 @@ func main() {
)
orch.SetToolRegistry(toolRegistry)
// 设置工具结果主动推送回调 — 通用,不绑定特定工具
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string) {
if thinker == nil {
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string, params orchestrator.SynthesizeParams) {
if thinker == nil || orch == nil {
return
}
// 通过 thinker 的主动消息机制推送
if userID == "" {
userID = adminUserID
}
thinker.TriggerReminderMessage(userID, sessionID, fmt.Sprintf("🔧 %s 执行完成:%s", toolName, result))
// 异步执行跟进:触发 LLM 生成回复并推送到原消息渠道
go func() {
var toolResult map[string]interface{}
if err := json.Unmarshal([]byte(result), &toolResult); err != nil {
toolResult = map[string]interface{}{"output": result}
}
output, _ := toolResult["output"].(string)
if output == "" {
output = result
}
followUpMsg := fmt.Sprintf("【系统消息】后台工具 %s 执行完成。结果:\n%s\n\n请基于以上结果生成回复发送给用户。", toolName, output)
// 持久化工具结果到会话历史(重启后不丢失)
ctxBuilder.CacheMessage(sessionID, model.RoleSystem,
fmt.Sprintf("[工具 %s 执行结果]\n%s", toolName, output))
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
UserID: userID,
SessionID: sessionID,
Message: followUpMsg,
Mode: "text",
Nickname: params.Nickname,
ChannelType: params.ChannelType,
ChannelID: params.ChannelID,
AdapterName: params.AdapterName,
})
if err != nil {
log.Printf("[tool-followup] ProcessInput 失败: %v", err)
return
}
var sb strings.Builder
for event := range eventCh {
if event.Type == model.StreamDelta {
sb.WriteString(event.Delta)
}
}
followUpResponse := sb.String()
if followUpResponse == "" {
return
}
// 推送到原平台渠道
if params.ChannelType != "" && params.ChannelID != "" && params.AdapterName != "" {
target := background.ProactiveTarget{
Platform: params.AdapterName,
ChatType: "private", // send-proactive 要求 private/group,不是 direct
}
if params.ChannelType == "group" {
target.ChatType = "group"
target.GroupID = params.ChannelID
} else {
target.UserID = strings.TrimPrefix(params.ChannelID, "private_")
}
log.Printf("[tool-followup] 推送跟进到 platform=%s chat=%s channel=%s len=%d",
target.Platform, target.ChatType, params.ChannelID, len(followUpResponse))
thinker.PushPlatformMessage(target, followUpResponse)
} else {
thinker.TriggerReminderMessage(userID, sessionID, followUpResponse)
}
}()
})
if visionProvider != nil {
@@ -593,6 +658,21 @@ func main() {
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/api/v1/debug/lock-holder", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
by, at := thinker.LockHolder()
json.NewEncoder(w).Encode(map[string]interface{}{
"locked_by": by,
"locked_at": at.Format(time.RFC3339),
"held_for": time.Since(at).String(),
})
})
mux.HandleFunc("/api/v1/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
buf := make([]byte, 1024*1024)
n := runtime.Stack(buf, true)
w.Write(buf[:n])
})
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
@@ -622,6 +702,7 @@ func main() {
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
@@ -714,10 +795,20 @@ func main() {
json.NewEncoder(w).Encode(result)
})
// 启动HTTP服务
// Debug: 全链路 HTTP 请求日志
debugMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
crashlog.WrapHTTP(mux).ServeHTTP(w, r)
elapsed := time.Since(start)
if elapsed > 2*time.Second || r.URL.Path != "/api/v1/health" {
log.Printf("[http] %s %s %v", r.Method, r.URL.Path, elapsed.Round(time.Millisecond))
}
})
// 启动HTTP服务(全局 panic 恢复 + 崩溃日志)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
Handler: debugMux,
}
go func() {
@@ -727,6 +818,21 @@ func main() {
}
}()
// Debug: 每30秒输出内存和goroutine统计
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
var m runtime.MemStats
for range ticker.C {
runtime.ReadMemStats(&m)
log.Printf("[stats] goroutines=%d heap=%dMB sys=%dMB gc=%d",
runtime.NumGoroutine(),
m.HeapAlloc/1024/1024,
m.Sys/1024/1024,
m.NumGC)
}
}()
// 优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -1013,7 +1119,8 @@ func handleChat(
return
}
ctx := r.Context()
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
// Inject admin flag for tool access control.
ctx = context.WithValue(ctx, plgManager.CtxKeyIsAdmin, req.IsAdmin)
@@ -1035,7 +1142,7 @@ func handleChat(
// Admin private messages: redirect to the main admin session so conversation
// history is shared across platforms (OBv11, web UI, etc.).
if req.UserID == "admin" && req.Source.ChannelType == "direct" && adminSessionID != "" {
if req.IsAdmin && req.Source.ChannelType == "direct" && adminSessionID != "" {
req.SessionID = adminSessionID
}
@@ -1051,6 +1158,9 @@ func handleChat(
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
// Trace: message received
AddTraceEvent("msg_received", req.SessionID, req.UserID, "收到消息: "+req.Message, "success", fmt.Sprintf("platform=%s", req.Source.Platform), 0, nil)
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
@@ -1058,7 +1168,12 @@ func handleChat(
}
// 1.5 缓存用户消息到会话历史(在 Orchestrator 之前,确保顺序正确:user → assistant
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
// 管理员主会话聚合多平台消息,加平台标签让 LLM 知道消息来源
userMsgForCache := req.Message
if req.IsAdmin && req.SessionID == adminSessionID && req.Source.AdapterName != "" {
userMsgForCache = fmt.Sprintf("[来自 %s] %s", req.Source.AdapterName, req.Message)
}
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, userMsgForCache)
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复