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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user