diff --git a/.gitignore b/.gitignore index daf585d..767da6e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ dist/ # ========== 子仓库 ========== backend/cyrene-plugins/ +# ========== 用户插件(独立项目,不进主仓库) ========== +backend/plugins/ + # ========== Go 编译二进制 ========== backend/ai-core/main backend/ai-core/cmd/main diff --git a/backend/ai-core/cmd/gen_plugins.go b/backend/ai-core/cmd/gen_plugins.go index fa23600..232a83c 100644 --- a/backend/ai-core/cmd/gen_plugins.go +++ b/backend/ai-core/cmd/gen_plugins.go @@ -1,38 +1,127 @@ //go:build ignore +// gen_plugins generates plugins_gen.go from: +// 1. plugins.json — built-in plugins (in cyrene-plugins) +// 2. ../plugins/*/plugin.json — user plugins (auto-discovered) +// +// Usage: go run gen_plugins.go + package main import ( "encoding/json" "fmt" "os" + "path/filepath" + "sort" "strings" ) type PluginEntry struct { Name string `json:"name"` - Import string `json:"import"` + Import string `json:"import,omitempty"` // built-in: explicit import Struct string `json:"struct"` Constructor string `json:"constructor,omitempty"` } -type PluginConfig struct { - Version string `json:"version"` - Plugins []PluginEntry `json:"plugins"` +type PluginManifest struct { + Name string `json:"name"` + Struct string `json:"struct"` } -func main() { +// ── load built-in plugins from plugins.json ── + +func loadBuiltinPlugins() ([]PluginEntry, error) { data, err := os.ReadFile("../plugins.json") if err != nil { - fmt.Fprintf(os.Stderr, "read plugins.json: %v\n", err) - os.Exit(1) + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read plugins.json: %w", err) + } + var cfg struct { + Version string `json:"version"` + Plugins []PluginEntry `json:"plugins"` } - var cfg PluginConfig if err := json.Unmarshal(data, &cfg); err != nil { - fmt.Fprintf(os.Stderr, "parse plugins.json: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("parse plugins.json: %w", err) + } + return cfg.Plugins, nil +} + +// ── auto-discover user plugins from ../plugins/ ── + +func discoverUserPlugins() ([]PluginEntry, error) { + pluginsDir := filepath.Join("..", "..", "plugins") + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read plugins dir: %w", err) } + var result []PluginEntry + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(pluginsDir, e.Name()) + + // 必须有 plugin.json + manifestPath := filepath.Join(dir, "plugin.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + continue + } + var m PluginManifest + if err := json.Unmarshal(manifestData, &m); err != nil { + fmt.Fprintf(os.Stderr, "⚠ skip %s: bad plugin.json: %v\n", e.Name(), err) + continue + } + if m.Name == "" { + m.Name = e.Name() + } + + // 从 go.mod 读取模块路径作为 import + modPath := filepath.Join(dir, "go.mod") + modData, err := os.ReadFile(modPath) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠ skip %s: no go.mod: %v\n", e.Name(), err) + continue + } + importPath := parseModule(modData) + if importPath == "" { + fmt.Fprintf(os.Stderr, "⚠ skip %s: cannot parse module from go.mod\n", e.Name()) + continue + } + + result = append(result, PluginEntry{ + Name: m.Name, + Import: importPath, + Struct: m.Struct, + }) + fmt.Printf(" ✓ discovered %s → %s\n", m.Name, importPath) + } + + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result, nil +} + +func parseModule(data []byte) string { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + return "" +} + +// ── generate plugins_gen.go ── + +func generate(plugins []PluginEntry) error { var sb strings.Builder sb.WriteString("// Code generated by gen_plugins.go; DO NOT EDIT.\n\n") sb.WriteString("package main\n\n") @@ -40,19 +129,22 @@ func main() { sb.WriteString("\tplgSDK \"git.yeij.top/AskaEth/Cyrene-Plugins/sdk\"\n") aliases := make(map[string]string) - for _, p := range cfg.Plugins { - alias := p.Name + "pkg" - aliases[p.Name] = alias - sb.WriteString(fmt.Sprintf("\t%s \"%s\"\n", alias, p.Import)) + imported := make(map[string]bool) + for _, p := range plugins { + pkgAlias := p.Name + "pkg" + aliases[p.Name] = pkgAlias + if !imported[p.Import] { + sb.WriteString(fmt.Sprintf("\t%s \"%s\"\n", pkgAlias, p.Import)) + imported[p.Import] = true + } } sb.WriteString(")\n\n") sb.WriteString("func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) {\n") - for _, p := range cfg.Plugins { + for _, p := range plugins { alias := aliases[p.Name] if p.Constructor != "" { - // Plugins with constructor (e.g. NewFilePlugin(dataDir)) if p.Name == "file_ops" || p.Name == "http_request" { sb.WriteString(fmt.Sprintf("\tfor _, t := range %s.%s(nil).Tools() {\n", alias, p.Constructor)) } else { @@ -69,8 +161,89 @@ func main() { outPath := "plugins_gen.go" if err := os.WriteFile(outPath, []byte(sb.String()), 0644); err != nil { - fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err) + return fmt.Errorf("write %s: %w", outPath, err) + } + fmt.Printf("Generated %s with %d plugins\n", outPath, len(plugins)) + return nil +} + +// ── auto-add require + replace directives to go.mod ── + +func ensureGoMod(userPlugins []PluginEntry) error { + modPath := filepath.Join("..", "go.mod") + data, err := os.ReadFile(modPath) + if err != nil { + return fmt.Errorf("read go.mod: %w", err) + } + content := string(data) + pluginsDir := filepath.Join("..", "..", "plugins") + entries, _ := os.ReadDir(pluginsDir) + + for _, e := range entries { + if !e.IsDir() { continue } + dir := filepath.Join(pluginsDir, e.Name()) + modData, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { continue } + mod := parseModule(modData) + if mod == "" { continue } + + relPath, _ := filepath.Rel(filepath.Join(".."), dir) + relPath = strings.ReplaceAll(relPath, "\\", "/") + + // ensure require + reqLine := fmt.Sprintf("\t%s v0.0.0\n", mod) + if !strings.Contains(content, reqLine) { + reqBlock := strings.Index(content, "require (") + if reqBlock < 0 { continue } + closeIdx := strings.Index(content[reqBlock:], ")") + if closeIdx < 0 { continue } + insertAt := reqBlock + closeIdx + content = content[:insertAt] + reqLine + content[insertAt:] + } + + // ensure replace + replaceLine := fmt.Sprintf("\t%s => %s\n", mod, relPath) + if !strings.Contains(content, replaceLine) { + replaceBlock := strings.Index(content, "replace (") + if replaceBlock < 0 { + content += fmt.Sprintf("\nreplace (\n%s)\n", replaceLine) + } else { + closeIdx := strings.Index(content[replaceBlock:], ")") + if closeIdx < 0 { continue } + insertAt := replaceBlock + closeIdx + content = content[:insertAt] + replaceLine + content[insertAt:] + } + } + } + + return os.WriteFile(modPath, []byte(content), 0644) +} + +func main() { + builtins, err := loadBuiltinPlugins() + if err != nil { + fmt.Fprintf(os.Stderr, "load builtins: %v\n", err) + os.Exit(1) + } + fmt.Printf("Built-in plugins: %d\n", len(builtins)) + + fmt.Println("Discovering user plugins...") + userPlugins, err := discoverUserPlugins() + if err != nil { + fmt.Fprintf(os.Stderr, "discover user plugins: %v\n", err) + os.Exit(1) + } + + // 自动补 go.mod replace 指令 + if len(userPlugins) > 0 { + if err := ensureGoMod(userPlugins); err != nil { + fmt.Fprintf(os.Stderr, "ensure go.mod: %v\n", err) + } + } + + all := append(builtins, userPlugins...) + if err := generate(all); err != nil { + fmt.Fprintf(os.Stderr, "generate: %v\n", err) os.Exit(1) } - fmt.Printf("Generated %s with %d plugins\n", outPath, len(cfg.Plugins)) } diff --git a/backend/ai-core/cmd/main.go b/backend/ai-core/cmd/main.go index dee3295..77409c2 100644 --- a/backend/ai-core/cmd/main.go +++ b/backend/ai-core/cmd/main.go @@ -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 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复 diff --git a/backend/ai-core/cmd/trace.go b/backend/ai-core/cmd/trace.go index 2f545a3..b859de8 100644 --- a/backend/ai-core/cmd/trace.go +++ b/backend/ai-core/cmd/trace.go @@ -58,7 +58,7 @@ func GetTraceEvents(sessionID string, limit int) []TraceEvent { if limit <= 0 || limit > len(traceEvents) { limit = len(traceEvents) } - var result []TraceEvent + result := make([]TraceEvent, 0) // Return newest first. for i := len(traceEvents) - 1; i >= 0 && len(result) < limit; i-- { ev := traceEvents[i] diff --git a/backend/ai-core/internal/background/thinker.go b/backend/ai-core/internal/background/thinker.go index 56be89e..f22bb18 100644 --- a/backend/ai-core/internal/background/thinker.go +++ b/backend/ai-core/internal/background/thinker.go @@ -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 +} diff --git a/backend/ai-core/internal/context/builder.go b/backend/ai-core/internal/context/builder.go index 45d72b7..eb33535 100644 --- a/backend/ai-core/internal/context/builder.go +++ b/backend/ai-core/internal/context/builder.go @@ -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 模型不支持自定义角色) diff --git a/backend/ai-core/internal/crashlog/crashlog.go b/backend/ai-core/internal/crashlog/crashlog.go new file mode 100644 index 0000000..c13c090 --- /dev/null +++ b/backend/ai-core/internal/crashlog/crashlog.go @@ -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) +} diff --git a/backend/ai-core/internal/llm/openai.go b/backend/ai-core/internal/llm/openai.go index 6350aaf..df291f3 100644 --- a/backend/ai-core/internal/llm/openai.go +++ b/backend/ai-core/internal/llm/openai.go @@ -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, }, } } diff --git a/backend/ai-core/internal/model/message.go b/backend/ai-core/internal/model/message.go index fc316ab..df02c10 100644 --- a/backend/ai-core/internal/model/message.go +++ b/backend/ai-core/internal/model/message.go @@ -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. diff --git a/backend/ai-core/internal/orchestrator/orchestrator.go b/backend/ai-core/internal/orchestrator/orchestrator.go index 7da2c88..3ed2f4c 100644 --- a/backend/ai-core/internal/orchestrator/orchestrator.go +++ b/backend/ai-core/internal/orchestrator/orchestrator.go @@ -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 diff --git a/backend/ai-core/internal/orchestrator/synthesizer.go b/backend/ai-core/internal/orchestrator/synthesizer.go index 4ef0e9f..ef88759 100644 --- a/backend/ai-core/internal/orchestrator/synthesizer.go +++ b/backend/ai-core/internal/orchestrator/synthesizer.go @@ -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), }) } diff --git a/backend/ai-core/internal/persona/cyrene_persona.yaml b/backend/ai-core/internal/persona/cyrene_persona.yaml index 404755b..98786bc 100644 --- a/backend/ai-core/internal/persona/cyrene_persona.yaml +++ b/backend/ai-core/internal/persona/cyrene_persona.yaml @@ -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程序" - 不能说"我是一个数字生命" diff --git a/backend/ai-core/internal/persona/injector.go b/backend/ai-core/internal/persona/injector.go index ab282a1..f8dd688 100644 --- a/backend/ai-core/internal/persona/injector.go +++ b/backend/ai-core/internal/persona/injector.go @@ -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 } diff --git a/backend/ai-core/internal/subsession/knowledge_provider.go b/backend/ai-core/internal/subsession/knowledge_provider.go index 432744f..0e9779b 100644 --- a/backend/ai-core/internal/subsession/knowledge_provider.go +++ b/backend/ai-core/internal/subsession/knowledge_provider.go @@ -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 diff --git a/backend/ai-core/plugins_gen.go b/backend/ai-core/plugins_gen.go new file mode 100644 index 0000000..895162b --- /dev/null +++ b/backend/ai-core/plugins_gen.go @@ -0,0 +1,10 @@ +// Code generated by gen_plugins.go; DO NOT EDIT. + +package main + +import ( + plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) { +} diff --git a/backend/platform-bridge/cmd/main.go b/backend/platform-bridge/cmd/main.go index 015947a..053819d 100644 --- a/backend/platform-bridge/cmd/main.go +++ b/backend/platform-bridge/cmd/main.go @@ -185,7 +185,7 @@ func main() { if err != nil { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), - Direction: "outgoing", + Direction: "error", Platform: msg.Platform, ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, @@ -196,7 +196,14 @@ func main() { }() } + // 戳一戳/动作消息:总是回复 + isPoke := strings.Contains(msg.Content, "【动作】") + switch { + case isPoke: + msg.RouteType = "normal" + response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) + case isMessageHistorical(msg, router): msg.RouteType = "silent" namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID) @@ -224,10 +231,12 @@ func main() { response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform} case isSilent: - msg.RouteType = "silent" + // 群聊环境消息:让 LLM 自己判断是否值得插话 + msg.RouteType = "group_ambient" + // 同时后台记录(记忆提取) namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID) fireSilent(namespace, imageURLs, videoURLs, voiceURLs) - response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform} + response, routeErr = forwardToAICore(cfg, msg, "group_ambient", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) default: msg.RouteType = "normal" @@ -237,7 +246,7 @@ func main() { if routeErr != nil { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), - Direction: "outgoing", + Direction: "error", Platform: msg.Platform, ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, @@ -282,9 +291,13 @@ func main() { mux := http.NewServeMux() bh := handler.NewBridgeHandler(router) bh.SetLogFunc(func(platform, channelID, senderID, content string, success bool) { + dir := "outgoing" + if !success { + dir = "error" + } msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), - Direction: "outgoing", + Direction: dir, Platform: platform, ChannelID: channelID, SenderID: senderID, @@ -439,6 +452,14 @@ func startOBv11Readers(router *bridge.PlatformRouter) { toSend = append(toSend, rm) } } + // NapCat 输入状态:私聊时在发送前显示"正在输入" + if messageType == "private" { + if cur, err := router.GetAdapter(adapterKey); err == nil { + if qa, ok := cur.(*qqadapter.Adapter); ok { + qa.SetTypingStatus(userID, 1) + } + } + } interval := time.Duration(adapter.SendIntervalMs()) * time.Millisecond if interval <= 0 { interval = 2 * time.Second @@ -470,6 +491,14 @@ func startOBv11Readers(router *bridge.PlatformRouter) { fmt.Printf("[qq:%s] send msg error: %v\n", adapterKey, sendErr) } } + // NapCat: clear typing indicator + if messageType == "private" { + if cur, err := router.GetAdapter(adapterKey); err == nil { + if qa, ok := cur.(*qqadapter.Adapter); ok { + qa.SetTypingStatus(userID, 0) + } + } + } } } }() @@ -501,13 +530,17 @@ func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAd } // Seed default adapters for platforms that have no stored config. + // Track platform types (not config names) to avoid duplicate adapters. seededTypes := map[string]bool{} for _, a := range adapters { seededTypes[a.PlatformName()] = true } + for _, stored := range store.List() { + seededTypes[stored.Platform] = true // stored.Platform is the type (e.g. "obv11") + } defaultPlatforms := []string{"obv11", "telegram", "webhook", "wechat", "feishu", "discord"} for _, name := range defaultPlatforms { - if seen[name] || seededTypes[name] { + if seededTypes[name] { continue } fields := mergeFields(cfg, name, nil) @@ -548,7 +581,14 @@ func createSingleAdapter(cfg *config.Config, platform, configName, configID stri sendIntervalMs = n } } - return qqadapter.NewAdapter(configID, configName, mode, port, token, remoteURL, sendIntervalMs) + adapter := qqadapter.NewAdapter(configID, configName, mode, port, token, remoteURL, sendIntervalMs) + // Optional HTTP API configuration (for typing status, etc.) + httpURL := fields["http_url"] + httpToken := fields["http_token"] + if httpURL != "" || httpToken != "" { + adapter.SetHTTPConfig(httpURL, httpToken) + } + return adapter case "telegram": token := cfg.TelegramToken if t, ok := fields["bot_token"]; ok && t != "" { diff --git a/backend/platform-bridge/internal/adapter/qq/adapter.go b/backend/platform-bridge/internal/adapter/qq/adapter.go index 4d11eb0..79d239c 100644 --- a/backend/platform-bridge/internal/adapter/qq/adapter.go +++ b/backend/platform-bridge/internal/adapter/qq/adapter.go @@ -32,8 +32,11 @@ type Adapter struct { port string accessToken string remoteURL string // NapCat OneBot WS server URL, used in client mode - sendIntervalMs int // minimum interval between consecutive messages - selfID string // bot's own QQ number, populated from incoming messages + httpURL string // NapCat HTTP API base URL (optional, derived from remoteURL if empty) + httpToken_ string // NapCat HTTP API access token (optional, uses accessToken if empty) + sendIntervalMs int // minimum interval between consecutive messages + lastTypingSent time.Time // last time typing indicator was sent, for min display duration + selfID string // bot's own QQ number, populated from incoming messages conn *websocket.Conn connMu sync.Mutex connected bool @@ -64,6 +67,14 @@ func NewAdapter(configID, configName, mode, port, accessToken, remoteURL string, } } +// SetHTTPConfig sets the optional HTTP API configuration. +func (a *Adapter) SetHTTPConfig(url, token string) { + a.httpURL = url + if token != "" { + a.httpToken_ = token + } +} + func (a *Adapter) PlatformName() string { return "obv11" } func (a *Adapter) ConfigID() string { return a.configID } func (a *Adapter) ConfigName() string { return a.configName } @@ -97,8 +108,12 @@ func (a *Adapter) SetGroupName(groupID int64, name string) { a.groupNamesMu.Unlock() } -// httpBase derives the HTTP base URL from the WebSocket remote URL. +// httpBase returns the HTTP API base URL. +// Uses configured httpURL if set, otherwise derives from WebSocket remoteURL. func (a *Adapter) httpBase() string { + if a.httpURL != "" { + return a.httpURL + } httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1) httpBase = strings.Replace(httpBase, "wss://", "https://", 1) if idx := strings.LastIndex(httpBase, "/"); idx > 8 { @@ -107,6 +122,48 @@ func (a *Adapter) httpBase() string { return httpBase } +// httpToken returns the HTTP API access token. +func (a *Adapter) httpToken() string { + if a.httpToken_ != "" { + return a.httpToken_ + } + return a.accessToken +} + +// SetTypingStatus sends a typing indicator to a private chat user. +// eventType: 1 = typing started, 0 = typing stopped. +// This uses the NapCat HTTP API (not standard OneBot v11). +// Enforces a minimum 3s display duration. +func (a *Adapter) SetTypingStatus(userID int64, eventType int) error { + if eventType == 0 { + // 确保"正在输入"至少显示了 3 秒 + if time.Since(a.lastTypingSent) < 3*time.Second { + time.Sleep(3*time.Second - time.Since(a.lastTypingSent)) + } + } else { + a.lastTypingSent = time.Now() + } + url := a.httpBase() + "/set_input_status" + if token := a.httpToken(); token != "" { + url += "?access_token=" + token + } + body, _ := json.Marshal(map[string]interface{}{ + "user_id": fmt.Sprintf("%d", userID), + "event_type": eventType, + }) + resp, err := http.Post(url, "application/json", strings.NewReader(string(body))) + if err != nil { + log.Printf("[qq] set_input_status error: %v (url=%s)", err, url) + return fmt.Errorf("set_input_status: %w", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + log.Printf("[qq] set_input_status HTTP %d (url=%s)", resp.StatusCode, url) + return fmt.Errorf("set_input_status: HTTP %d", resp.StatusCode) + } + return nil +} + // fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode). func (a *Adapter) fetchGroupName(groupID int64) { if a.mode != "client" || a.remoteURL == "" { diff --git a/backend/platform-bridge/internal/handler/bridge_handler.go b/backend/platform-bridge/internal/handler/bridge_handler.go index 1db4e9e..914ab68 100644 --- a/backend/platform-bridge/internal/handler/bridge_handler.go +++ b/backend/platform-bridge/internal/handler/bridge_handler.go @@ -7,11 +7,21 @@ import ( "net/http" "os" "strconv" + "strings" "sync" + "regexp" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge" ) +// Regex patterns for markdown stripping. +var ( + mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`) + mdItalicRe = regexp.MustCompile(`\*(.+?)\*`) + mdStrikethroughRe = regexp.MustCompile(`~~(.+?)~~`) + mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`) +) + // BridgeHandler exposes the Platform Bridge REST API. type BridgeHandler struct { router *bridge.PlatformRouter @@ -200,39 +210,57 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) { userID := parseIntSafe(req.UserID) groupID := parseIntSafe(req.GroupID) + // 过滤 / 标签,去除 markdown 标记 + content := filterActions(req.Content) + content = convertMarkdownPlain(content) + + // 按 \n\n 和 ♪ 拆分为多条消息 + messages := splitProactiveContent(content) + // Prepend CQ @mention tag if at_user_id is specified - content := req.Content + atPrefix := "" if req.AtUserID != "" { - content = fmt.Sprintf("[CQ:at,qq=%s] %s", req.AtUserID, content) + atPrefix = fmt.Sprintf("[CQ:at,qq=%s] ", req.AtUserID) } - // Resolve adapter: try exact name first, then find by platform type. + // Resolve adapter adapterName := req.Platform - err := h.router.SendProactive(adapterName, msgType, userID, groupID, content) - if err != nil { - for _, name := range h.router.ListAdapters() { - if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() { - adapterName = name - err = h.router.SendProactive(name, msgType, userID, groupID, content) - break + var sendErr error + for i, msg := range messages { + fullMsg := atPrefix + msg + if i > 0 { + atPrefix = "" // only first message gets @mention + } + sendErr = h.router.SendProactive(adapterName, msgType, userID, groupID, fullMsg) + if sendErr != nil { + // Fallback: try other adapters with same platform name + for _, name := range h.router.ListAdapters() { + if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() { + adapterName = name + sendErr = h.router.SendProactive(name, msgType, userID, groupID, fullMsg) + break + } } } + if sendErr != nil { + log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, sendErr) + break + } + log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d msg=%d/%d", + adapterName, msgType, userID, groupID, req.AtUserID, len(fullMsg), i+1, len(messages)) + if h.logFn != nil { + chID := req.GroupID + if msgType == "private" { + chID = req.UserID + } + h.logFn(req.Platform, chID, "Cyrene", fullMsg, true) + } } - if err != nil { - log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, err) - writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+err.Error())) + if sendErr != nil { + writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+sendErr.Error())) return } - log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d", - adapterName, msgType, userID, groupID, req.AtUserID, len(content)) - if h.logFn != nil { - chID := req.GroupID - if msgType == "private" { - chID = req.UserID - } - h.logFn(req.Platform, chID, "Cyrene", content, true) - } writeJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "消息已发送", @@ -259,3 +287,71 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.WriteHeader(status) json.NewEncoder(w).Encode(data) } + +// filterActions removes and tags and their content. +func filterActions(text string) string { + tags := [][2]string{ + {"", ""}, + {"", ""}, + } + for _, t := range tags { + openTag, closeTag := t[0], t[1] + for { + start := strings.Index(text, openTag) + if start == -1 { + break + } + end := strings.Index(text[start:], closeTag) + if end == -1 { + text = text[:start] + text[start+len(openTag):] + continue + } + text = text[:start] + text[start+end+len(closeTag):] + } + } + return strings.TrimSpace(text) +} + +// convertMarkdownPlain strips basic markdown formatting. +func convertMarkdownPlain(md string) string { + md = mdBoldRe.ReplaceAllString(md, "$1") + md = mdItalicRe.ReplaceAllString(md, "$1") + md = mdStrikethroughRe.ReplaceAllString(md, "$1") + md = mdHeadingRe.ReplaceAllString(md, "") + return md +} + +// splitProactiveContent splits long proactive content into multiple messages. +// Strategy same as splitContent in cmd/main.go: split by \n\n, then by ♪. +func splitProactiveContent(text string) []string { + rawParts := strings.Split(text, "\n\n") + var parts []string + for _, p := range rawParts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if strings.Contains(p, "♪") { + for _, sub := range strings.Split(p, "♪") { + sub = strings.TrimSpace(sub) + if sub != "" { + parts = append(parts, sub) + } + } + } else { + parts = append(parts, p) + } + } + + // Merge very short segments with neighbors (min 8 runes). + const minRunes = 8 + var merged []string + for _, part := range parts { + if len([]rune(part)) < minRunes && len(merged) > 0 { + merged[len(merged)-1] = merged[len(merged)-1] + part + } else { + merged = append(merged, part) + } + } + return merged +} diff --git a/ethend/public/icons.js b/ethend/public/icons.js new file mode 100644 index 0000000..4506b88 --- /dev/null +++ b/ethend/public/icons.js @@ -0,0 +1,146 @@ +// Cyrene ethend — Material Design 3 Vector Icon Library +// Usage: icon('home', 18) → '' +(function() { + const PATHS = { + // Navigation + home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z', + menu: 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z', + apps: 'M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z', + monitor: 'M20 3H4c-1.1 0-2 .9-2 2v11c0 1.1.9 2 2 2h3l-1 1v2h12v-2l-1-1h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 13H4V5h16v11z', + bar_chart: 'M5 9.2h3V19H5zM10.6 5h2.8v14h-2.8zm5.6 8H19v6h-2.8z', + database: 'M12 3C7.58 3 4 4.79 4 7v10c0 2.21 3.58 4 8 4s8-1.79 8-4V7c0-2.21-3.58-4-8-4zm0 2c3.87 0 6 1.5 6 2s-2.13 2-6 2-6-1.5-6-2 2.13-2 6-2zM6 12c0 .5 2.13 2 6 2s6-1.5 6-2v2.5c0 .5-2.13 2-6 2s-6-1.5-6-2V12zm0 5c0 .5 2.13 2 6 2s6-1.5 6-2v2.5c0 .5-2.13 2-6 2s-6-1.5-6-2V17z', + link: 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z', + build: 'M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z', + settings: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z', + memory: 'M15 9H9v6h6V9zm-2 4h-2v-2h2v2zm8-2V9h-2V7c0-1.1-.9-2-2-2h-2V3h-2v2h-2V3H9v2H7c-1.1 0-2 .9-2 2v2H3v2h2v2H3v2h2v2c0 1.1.9 2 2 2h2v2h2v-2h2v2h2v-2h2c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2zm-4 6H7V7h10v10z', + psychiatry: 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0013 21a9 9 0 000-18zm-1 5v5h2v-5h-2zm0 7v2h2v-2h-2z', + smart_toy: 'M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3zM7.5 11.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5S9.83 13 9 13s-1.5-.67-1.5-1.5zM16 17H8v-2h8v2zm-1-4c-.83 0-1.5-.67-1.5-1.5S14.17 10 15 10s1.5.67 1.5 1.5S15.83 13 15 13z', + // Status + schedule: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z', + check_circle: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z', + error_outline: 'M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z', + warning_amber: 'M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-4h-2v4h2v-4z', + hourglass_bottom:'M18 7V4h2V2H4v2h2v3c0 2.09 1.07 3.93 2.69 5A5.98 5.98 0 006 17v3H4v2h16v-2h-2v-3c0-2.09-1.07-3.93-2.69-5A5.98 5.98 0 0018 7z', + circle: 'M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2z', + // Actions + refresh: 'M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z', + play_arrow: 'M8 5v14l11-7z', + stop: 'M6 6h12v12H6z', + restart_alt: 'M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z', + favorite: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z', + delete: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z', + search: 'M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z', + add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', + edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a.996.996 0 000-1.41l-2.34-2.34a.996.996 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', + close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', + save: 'M17 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z', + power_settings_new:'M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.938 6.938 0 0119 12c0 3.87-3.13 7-7 7A6.995 6.995 0 017.58 6.58L6.17 5.17A8.959 8.959 0 003 12a9 9 0 0018 0c0-2.74-1.23-5.18-3.17-6.83z', + download: 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z', + upload: 'M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z', + // IoT + ac_unit: 'M22 11h-4.17l3.24-3.24-1.41-1.42L15 11h-2V9l4.66-4.66-1.42-1.41L13 6.17V2h-2v4.17L7.76 2.93 6.34 4.34 11 9v2H9L4.34 6.34 2.93 7.76 6.17 11H2v2h4.17l-3.24 3.24 1.41 1.42L9 13h2v2l-4.66 4.66 1.42 1.41L11 17.83V22h2v-4.17l3.24 3.24 1.42-1.41L13 15v-2h2l4.66 4.66 1.41-1.42L17.83 13H22v-2z', + lightbulb: 'M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z', + lock: 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z', + sensors: 'M7.76 16.24C6.67 15.16 6 13.66 6 12s.67-3.16 1.76-4.24l1.42 1.42C8.45 9.9 8 10.9 8 12c0 1.1.45 2.1 1.17 2.83l-1.41 1.41zm8.48 0C17.33 15.16 18 13.66 18 12s-.67-3.16-1.76-4.24l-1.42 1.42C15.55 9.9 16 10.9 16 12c0 1.1-.45 2.1-1.17 2.83l1.41 1.41zM12 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8 2c0 2.21-.9 4.21-2.35 5.65l1.42 1.42C20.88 17.26 22 14.76 22 12s-1.12-5.26-2.93-7.07l-1.42 1.42A7.94 7.94 0 0120 12zM6.35 6.35L4.93 4.93C3.12 6.74 2 9.24 2 12s1.12 5.26 2.93 7.07l1.42-1.42A7.94 7.94 0 014 12c0-2.21.9-4.21 2.35-5.65z', + thermostat: 'M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-4-8c0-.55.45-1 1-1s1 .45 1 1h-1v1h1v2h-1v1h1v2h-2V5z', + // Communication + person: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z', + chat: 'M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z', + mic: 'M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15a.998.998 0 00-.98-.85c-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08a6.993 6.993 0 005.91-5.78c.1-.6-.39-1.14-1-1.14z', + devices_other: 'M3 6h18V4H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4v-2H3V6zm10 6H9v1.78c-.61.55-1 1.33-1 2.22 0 .89.39 1.67 1 2.22V20h4v-1.78c.61-.55 1-1.34 1-2.22s-.39-1.67-1-2.22V12zm-2 5.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM22 8h-6c-.5 0-1 .5-1 1v10c0 .5.5 1 1 1h6c.5 0 1-.5 1-1V9c0-.5-.5-1-1-1zm-1 10h-4v-8h4v8z', + extension: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5a2.5 2.5 0 00-5 0V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5a2.5 2.5 0 000-5z', + deployed_code: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM9.5 16.5v-9l7 4.5-7 4.5z', + // Misc + star: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z', + push_pin: 'M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z', + list_alt: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 8h10v2H7V8zm0 4h10v2H7v-2zm0 4h7v2H7v-2z', + inventory_2: 'M20 2H4c-1 0-2 .9-2 2v3.01c0 .72.43 1.34 1 1.69V20c0 1.1 1.1 2 2 2h14c.9 0 2-.9 2-2V8.7c.57-.35 1-.97 1-1.69V4c0-1.1-1-2-2-2zm-1 18H5V9h14v11zm1-13H4V4h16v3z', + task_alt: 'M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z', + edit_note: 'M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm14-1v-2h-2v2h-2v2h2v2h2v-2h2v-2h-2z', + mail_outline: 'M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8l8 5 8-5v10zm-8-7L4 6h16l-8 5z', + bolt: 'M14 2H6L2 14h8l-2 8 12-12h-8l4-8z', + calendar_month: 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20a2 2 0 002 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm-8 4H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z', + menu_book: 'M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1zm0 13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5V8c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5v11.5z', + trending_flat: 'M22 12l-4-4v3H3v2h15v3z', + trending_up: 'M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z', + trending_down: 'M16 18l2.29-2.29-4.88-4.88-4 4L2 7.41 3.41 6l6 6 4-4 6.3 6.29L22 12v6z', + diamond: 'M19 3H5L2 9l10 12L22 9l-3-6zM9.62 8L12 5.67 14.38 8H9.62z', + timer: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z', + volume_up: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z', + }; + + window.icon = function(name, size) { + var path = PATHS[name]; + if (!path) return ''; + var s = size || 20; + return ''; + }; +})(); + +// Runtime emoji→icon replacement on page load +document.addEventListener('DOMContentLoaded', function() { + const MAP = { + '🏠': 'home', '🧠': 'memory', '💬': 'chat', '💭': 'psychiatry', + '⏱️': 'schedule', '⏱': 'schedule', '🖥': 'monitor', '📊': 'bar_chart', + '🗄️': 'database', '🔗': 'link', '🔧': 'build', '🎤': 'mic', + '🔌': 'extension', '📱': 'devices_other', '🤖': 'smart_toy', + '📡': 'sensors', '⚡': 'bolt', '💻': 'monitor', '🛠️': 'settings', + '▶': 'play_arrow', '⏹': 'stop', '🔄': 'refresh', '🔁': 'restart_alt', + '🔍': 'search', '🗑': 'delete', '✏': 'edit', '💾': 'save', '➕': 'add', '❌': 'close', + '✅': 'check_circle', '⚠️': 'warning_amber', '⚠': 'warning_amber', '📭': 'mail_outline', '⏳': 'hourglass_bottom', + '💡': 'lightbulb', '🎯': 'task_alt', '📥': 'download', '📤': 'upload', '👤': 'person', + '📝': 'edit_note', '📋': 'list_alt', '📄': 'menu_book', '⏰': 'schedule', '📅': 'calendar_month', + '🔔': 'notifications', '⭐': 'star', '❤️': 'favorite', '❤': 'favorite', '🎵': 'volume_up', + '🌡️': 'thermostat', '💧': 'ac_unit', '🔒': 'lock', '🔓': 'lock', '📌': 'push_pin', '🐳': 'deployed_code', + '📚': 'menu_book', '🔴': 'circle', '🔀': 'refresh', '⚙': 'settings', '📁': 'inventory_2', + '📦': 'inventory_2', '🔨': 'build', '💜': 'favorite', '✕': 'close', + '🏷': 'list_alt', '🤔': 'psychiatry', '○': 'circle', '💕': 'favorite', + '★': 'star', '☆': 'star', '✓': 'check_circle', '✗': 'close', '🎙': 'mic', + '🕐': 'schedule', '↓': 'trending_down', '↑': 'trending_up', '→': 'trending_flat', + '📭': 'mail_outline' + }; + + function replaceIn(el) { + if (!el) return; + // Text nodes + for (var i = 0; i < (el.childNodes || []).length; i++) { + var node = el.childNodes[i]; + if (node.nodeType === 3 && node.textContent) { + var text = node.textContent; + var changed = false; + for (var emoji in MAP) { + if (text.indexOf(emoji) >= 0) { + text = text.split(emoji).join(''); + changed = true; + // Insert icon before the text node + var span = document.createElement('span'); + span.innerHTML = icon(MAP[emoji], 16); + span.style.cssText = 'vertical-align:middle;display:inline-flex;align-items:center'; + node.parentNode.insertBefore(span, node); + } + } + if (changed && text.trim()) { + node.textContent = text; + } else if (changed) { + node.textContent = ''; + } + } else if (node.nodeType === 1) { + // Skip script/style/icon elements + if (!/SCRIPT|STYLE|SVG|PATH/i.test(node.tagName)) { + replaceIn(node); + } + } + } + } + + // Debounce for dynamic content + var timer; + var observer = new MutationObserver(function() { + clearTimeout(timer); + timer = setTimeout(function() { replaceIn(document.body); }, 200); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + // Initial run + replaceIn(document.body); +}); diff --git a/ethend/public/index.html b/ethend/public/index.html index 5725bd9..532031e 100644 --- a/ethend/public/index.html +++ b/ethend/public/index.html @@ -5,32 +5,37 @@ Cyrene ethend