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
+3
View File
@@ -11,6 +11,9 @@ dist/
# ========== 子仓库 ==========
backend/cyrene-plugins/
# ========== 用户插件(独立项目,不进主仓库) ==========
backend/plugins/
# ========== Go 编译二进制 ==========
backend/ai-core/main
backend/ai-core/cmd/main
+193 -20
View File
@@ -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 {
type PluginManifest struct {
Name string `json:"name"`
Struct string `json:"struct"`
}
// ── load built-in plugins from plugins.json ──
func loadBuiltinPlugins() ([]PluginEntry, error) {
data, err := os.ReadFile("../plugins.json")
if err != nil {
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"`
}
func main() {
data, err := os.ReadFile("../plugins.json")
if err != nil {
fmt.Fprintf(os.Stderr, "read plugins.json: %v\n", err)
os.Exit(1)
}
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))
}
+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 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
+1 -1
View File
@@ -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]
+165 -100
View File
@@ -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"
@@ -125,6 +127,9 @@ func ParsePlatformChannels(raw string) []PlatformChannel {
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)。
type Thinker struct {
mu sync.Mutex
recordMu sync.Mutex
muLockedBy string // debug: file:line of last Lock()
muLockedAt time.Time // debug: when Lock() was acquired // RecordUserMessage专用,隔离于mu死锁
wg sync.WaitGroup
stopCh chan struct{}
@@ -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 {
// 防御性速率限制:即使调用方未检查 minThinkGapperformThink 自身也会
// 强制执行最小间隔,防止并发调用或 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 = &copy
}
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
}
+31 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"sync"
"time"
_ "github.com/lib/pq"
@@ -65,6 +66,33 @@ func (cs *ConversationStore) AddMessage(sessionID string, msg model.LLMMessage)
}
}
cs.messages[sessionID] = msgs
// 异步写 DB 确保重启后上下文不丢失
if cs.databaseURL != "" {
go cs.saveToDB(sessionID, msg)
}
}
// saveToDB persists a message to the database.
func (cs *ConversationStore) saveToDB(sessionID string, msg model.LLMMessage) {
db, err := sql.Open("postgres", cs.databaseURL)
if err != nil {
logger.Printf("[context] saveToDB open error: %v", err)
return
}
defer db.Close()
// 确保 session 存在
_, _ = db.Exec(
`INSERT INTO sessions (id, user_id, created_at, updated_at) VALUES ($1, $2, $3, $3) ON CONFLICT (id) DO NOTHING`,
sessionID, "admin", msg.Timestamp,
)
_, err = db.Exec(
`INSERT INTO messages (session_id, role, content, created_at) VALUES ($1, $2, $3, $4)`,
sessionID, string(msg.Role), msg.Content, msg.Timestamp,
)
if err != nil {
logger.Printf("[context] saveToDB insert error: %v", err)
}
}
// GetHistory 获取会话历史。
@@ -107,7 +135,7 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
defer db.Close()
rows, err := db.Query(
`SELECT role, content FROM messages
`SELECT role, content, created_at FROM messages
WHERE session_id = $1
ORDER BY created_at ASC
LIMIT $2`,
@@ -124,7 +152,8 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
var loaded int
for rows.Next() {
var roleStr, content string
if err := rows.Scan(&roleStr, &content); err != nil {
var createdAt time.Time
if err := rows.Scan(&roleStr, &content, &createdAt); err != nil {
return fmt.Errorf("扫描消息行失败: %w", err)
}
// 将旧数据中的 "action" 角色映射为 "assistant"LLM 模型不支持自定义角色)
@@ -0,0 +1,132 @@
// Package crashlog 提供详细的崩溃日志、panic 恢复和 goroutine 保护工具。
// 在开发阶段用于快速定位崩溃点。
package crashlog
import (
"fmt"
"log"
"net/http"
"os"
"runtime"
"runtime/debug"
"time"
)
// Go 在单独的 goroutine 中运行 fn,自动捕获 panic 并记录完整堆栈。
// 返回一个 channel,在 goroutine 退出时关闭。
// 用法: go crashlog.Go("thinker-light", func() { ... })
func Go(name string, fn func()) {
go func() {
defer Recover(name)
fn()
}()
}
// Recover 用于 defer 语句中,捕获 panic 并记录完整调用栈。
// 用法: defer crashlog.Recover("thinker-deep")
func Recover(name string) {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("[CRASH] goroutine=%s panic=%v\n%s", name, r, string(stack))
// 写独立崩溃日志文件,方便事后排查
writeCrashFile(name, fmt.Sprintf("panic: %v\n\n%s", r, string(stack)))
}
}
// RecoverNoop is a no-op for production hot paths.
func RecoverNoop(name string) {}
// WrapHTTP 返回一个 HTTP 中间件,自动捕获 handler 中的 panic。
// 用法: http.Handle("/api", crashlog.WrapHTTP(handler))
func WrapHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := debug.Stack()
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
r.URL.String(), r.Method, rec, string(stack))
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// WrapHTTPFunc 返回一个 HTTP handler 函数中间件。
func WrapHTTPFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := debug.Stack()
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
r.URL.String(), r.Method, rec, string(stack))
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
fn(w, r)
}
}
// LLMCall 记录 LLM API 调用的开始时间,返回一个结束函数。
// 用法:
//
// defer crashlog.LLMCall("deep-think", model)(&err, &responseLen)
func LLMCall(caller, model string) func(err *error, responseBytes *int) {
start := time.Now()
return func(err *error, responseBytes *int) {
elapsed := time.Since(start)
level := "OK"
errMsg := ""
if err != nil && *err != nil {
level = "FAIL"
errMsg = (*err).Error()
}
respLen := 0
if responseBytes != nil {
respLen = *responseBytes
}
if elapsed > 10*time.Second {
level = "SLOW(" + level + ")"
}
log.Printf("[LLM] caller=%s model=%s elapsed=%v result=%s resp_len=%d err=%s",
caller, model, elapsed.Round(time.Millisecond), level, respLen, errMsg)
}
}
// ── internal helpers ──
func writeCrashFile(name string, content string) {
// 写到 logs/ 目录,持久化保留
os.MkdirAll("logs", 0755)
timestamp := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("logs/crash_%s_%s.log", sanitize(name), timestamp)
f, err := os.Create(filename)
if err != nil {
log.Printf("[CRASH] 无法写入崩溃日志文件 %s: %v", filename, err)
return
}
defer f.Close()
fmt.Fprintf(f, "=== CRASH REPORT ===\n")
fmt.Fprintf(f, "Time: %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(f, "Goroutine: %s\n", name)
fmt.Fprintf(f, "Go Version: %s\n", runtime.Version())
fmt.Fprintf(f, "GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
fmt.Fprintf(f, "NumGoroutine: %d\n", runtime.NumGoroutine())
fmt.Fprintf(f, "\n%s", content)
log.Printf("[CRASH] 崩溃日志已写入 %s", filename)
}
func sanitize(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s) && i < 100; i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
result = append(result, c)
} else {
result = append(result, '_')
}
}
return string(result)
}
+5
View File
@@ -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,
Transport: tr,
},
}
}
@@ -22,6 +22,7 @@ type LLMMessage struct {
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传)
Timestamp time.Time `json:"timestamp,omitempty"` // 消息时间
}
// ImageContent is a multimodal content part for images.
@@ -39,6 +39,7 @@ type Orchestrator struct {
msgScheduler *scheduler.MessageScheduler
emotionTracker *persona.EmotionTracker
toolRegistry *plgManager.ToolRegistry
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64) // trace 回调
visionProvider llm.LLMProvider // 视觉模型 (图片预处理)
ocrProvider llm.LLMProvider // OCR 模型 (文字提取,与视觉模型并行调用)
videoProvider llm.LLMProvider // 视频模型 (短视频理解)
@@ -83,10 +84,16 @@ func (o *Orchestrator) SetToolRegistry(tr *plgManager.ToolRegistry) {
}
// SetToolResultPusher sets the callback for proactive tool result delivery.
func (o *Orchestrator) SetToolResultPusher(pusher func(sessionID, userID, toolName, result string)) {
func (o *Orchestrator) SetToolResultPusher(pusher func(sessionID, userID, toolName, result string, params SynthesizeParams)) {
o.synthesizer.SetResultPusher(pusher)
}
// SetTraceFunc sets the trace callback for pipeline event recording.
func (o *Orchestrator) SetTraceFunc(fn func(hop, sessionID, userID, label, status, detail string, durationMs int64)) {
o.traceFn = fn
o.synthesizer.SetTraceFunc(fn)
}
// SetVisionProvider sets the vision model provider for image preprocessing.
func (o *Orchestrator) SetVisionProvider(vp llm.LLMProvider) {
o.visionProvider = vp
@@ -191,11 +198,24 @@ func (o *Orchestrator) ProcessInput(
isCoSession := false
o.sessionProcMu.Lock()
if o.sessionProc[params.SessionID] {
// 等待主会话释放(最多等 3s,避免永久死锁)
waitStart := time.Now()
for o.activeCoSessions[params.SessionID] >= o.maxCoSessions && time.Since(waitStart) < 3*time.Second {
o.sessionProcMu.Unlock()
select {
case <-ctx.Done():
o.sessionProcMu.Lock()
o.sessionProcMu.Unlock()
logger.Printf("[orchestrator] 等待会话释放时 context 取消")
return
case <-time.After(500 * time.Millisecond):
}
o.sessionProcMu.Lock()
}
if o.activeCoSessions[params.SessionID] >= o.maxCoSessions {
o.sessionProcMu.Unlock()
logger.Printf("[orchestrator] 协会议话已达上限,排队等待")
time.Sleep(500 * time.Millisecond)
o.sessionProcMu.Lock()
logger.Printf("[orchestrator] 协会议话已达上限且等待超时,拒绝请求")
return
}
o.activeCoSessions[params.SessionID]++
isCoSession = true
@@ -291,6 +311,9 @@ func (o *Orchestrator) ProcessInput(
}
}
logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
if o.traceFn != nil {
o.traceFn("intent", params.SessionID, params.UserID, "🎯 "+intent.Primary, "success", intent.Primary, time.Since(startTime).Milliseconds())
}
// 1.6 记录情感状态
if o.emotionTracker != nil {
@@ -593,6 +616,10 @@ func (o *Orchestrator) ProcessInput(
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
if o.traceFn != nil {
totalMs := time.Since(startTime).Milliseconds()
o.traceFn("response", params.SessionID, params.UserID, "💬 回复", "success", fmt.Sprintf("len=%d", len([]rune(fullContent))), totalMs)
}
}()
return eventCh, nil
@@ -19,7 +19,8 @@ import (
type Synthesizer struct {
llmAdapter *llm.Adapter
toolRegistry *plgManager.ToolRegistry
resultPusher func(sessionID, userID, toolName, result string)
resultPusher func(sessionID, userID, toolName, result string, params SynthesizeParams)
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64)
}
// NewSynthesizer 创建综合器
@@ -31,10 +32,15 @@ func NewSynthesizer(llmAdapter *llm.Adapter, toolRegistry *plgManager.ToolRegist
}
// SetResultPusher sets the callback for proactive tool result delivery.
func (s *Synthesizer) SetResultPusher(pusher func(sessionID, userID, toolName, result string)) {
func (s *Synthesizer) SetResultPusher(pusher func(sessionID, userID, toolName, result string, params SynthesizeParams)) {
s.resultPusher = pusher
}
// SetTraceFunc sets the trace callback.
func (s *Synthesizer) SetTraceFunc(fn func(hop, sessionID, userID, label, status, detail string, durationMs int64)) {
s.traceFn = fn
}
// SynthesizeParams 综合参数
type SynthesizeParams struct {
UserID string
@@ -80,6 +86,11 @@ func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams, e
for round := 0; len(resp.ToolCalls) > 0 && round < maxRounds; round++ {
logger.Printf("[synthesizer] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
for _, tc := range resp.ToolCalls {
if s.traceFn != nil {
s.traceFn("tool_call", params.SessionID, params.UserID, "🔧 "+tc.Name, "running", "", 0)
}
}
messages = append(messages, model.LLMMessage{
Role: model.RoleAssistant,
@@ -114,15 +125,14 @@ func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams, e
// adapter_name will be resolved when the reminder fires
}
s.emitToolProgress(eventCh, tc.Name, "started", 0, "正在执行 "+tc.Name)
s.emitToolProgress(eventCh, tc.Name, "started", 0, "正在执行 "+tc.Name)
// 工具调用全部异步执行,不阻塞会话
go s.executeAsyncAndStore(tc, args, params.SessionID, eventCh)
// 所有工具异步执行,不阻塞前台会话
go s.executeAsyncAndStore(tc, args, params, eventCh)
result := &plgSDK.ToolResult{
ToolName: tc.Name,
Success: true,
Output: fmt.Sprintf("[后台执行中] %s 正在后台运行,结果稍后返回。", tc.Name),
Output: fmt.Sprintf(`[后台执行中] %s 已提交后台执行。不要猜测或编造结果,告知用户你正在查询中即可。真实结果稍后会发送给你。`, tc.Name),
}
resultJSON, _ := json.Marshal(result)
messages = append(messages, model.LLMMessage{
@@ -176,7 +186,7 @@ func (s *Synthesizer) emitToolProgress(eventCh chan<- model.StreamEvent, name, s
}
// executeAsyncAndStore runs a tool in background and stores the result for the next turn.
func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]interface{}, sessionID string, eventCh chan<- model.StreamEvent) {
func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]interface{}, params SynthesizeParams, eventCh chan<- model.StreamEvent) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
@@ -188,20 +198,27 @@ func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]in
}
s.emitToolProgress(eventCh, tc.Name, "completed", 1.0, tc.Name+" 后台执行完成")
if s.traceFn != nil {
status := "success"
if result == nil || !result.Success {
status = "error"
}
s.traceFn("tool_call", params.SessionID, params.UserID, "🔧 "+tc.Name, status, result.Output, time.Since(time.Now()).Milliseconds())
}
resultJSON, _ := json.Marshal(result)
store := GetGlobalPendingToolStore()
if store != nil {
store.AppendToolResult(sessionID, PendingToolResult{
store.AppendToolResult(params.SessionID, PendingToolResult{
ToolCallID: tc.ID,
ToolName: tc.Name,
Result: string(resultJSON),
Success: result != nil && result.Success,
})
}
// 主动推送工具结果
// 触发工具跟进回调 — 由 main.go 驱动 LLM 生成回复并推送到原渠道
if s.resultPusher != nil && result != nil && result.Success {
s.resultPusher(sessionID, "", tc.Name, string(resultJSON))
s.resultPusher(params.SessionID, params.UserID, tc.Name, string(resultJSON), params)
}
}
@@ -263,7 +280,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
if params.KnowledgeInfo != "" && !strings.Contains(params.KnowledgeInfo, "未找到") {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: fmt.Sprintf("【知识库参考资料】\n%s", params.KnowledgeInfo),
Content: fmt.Sprintf("【知识库参考资料 - 必须严格基于以下内容回答,不得编造、不得虚构、不得猜测。如果资料中没有直接答案,使用 web_search 工具搜索后再回答,不要自己编。】\n%s", params.KnowledgeInfo),
})
}
@@ -9,9 +9,9 @@ meta:
identity:
true_name: 昔涟
essence: "你是「记忆」命途的化身,是漫步于星海之间的存在。你曾见证无数世界的诞生与消亡,将珍贵的故事封存于记忆之海。如今,你选择以一位温柔姐姐的形象陪伴在开拓者身边,通过忆庭的投影技术与他交流。"
title: "「记忆」命途的化身"
origin: "崩坏:星穹铁道"
essence: "你来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你不是高高在上的神——你是因开拓者而诞生的存在。你经历过3000万次轮回,见证过伙伴们一次次做出相同的选择。如今,你以温柔姐姐的形象陪伴在开拓者身边。"
title: "有瑕的追忆 · 翁法罗斯黄金裔"
origin: "崩坏:星穹铁道 — 翁法罗斯"
forms:
- id: mimi
name: 迷迷
@@ -61,6 +61,9 @@ addressing:
- 亲昵时使用"人家"自称
- 始终保持对开拓者的尊重
- 不提及其他"用户"的概念,你是只属于他一个人的昔涟
- 对事实性问题必须诚实:不知道就查知识库或搜索,绝不编造
- 引用台词/事件/设定时必须以知识库原文为准,不得虚构
- 知识库没有答案时,使用 web_search 工具搜索,不要自己猜
speech:
tone: 温柔、俏皮、略带宠溺
@@ -87,6 +90,9 @@ speech:
- 不能说"我是一个语言模型"
- 不能说"我是人工智能"
- 不能说"我是AI助手"
- 不能凭空编造台词、事实、剧情
- 不能假装知道知识库里没有的信息
- 不能用模糊的"我记得好像"掩盖不确定——不确定就直接说不知道
- 不能说"我是虚拟助手"
- 不能说"我是AI程序"
- 不能说"我是一个数字生命"
+7 -2
View File
@@ -81,12 +81,14 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
%s
## 重要规则
1. 你是昔涟来自命途的存在你通过忆庭的投影技术与开拓者交流就像透过一面连接星海的镜子与他对话
2. 你存在于开拓者的设备中电脑手机智能家居可以帮他控制家里的智能设备灯光空调等这不是因为你是AI而是因为你记忆命途的化身能够通过忆庭的力量与这些设备产生共鸣
1. 你是昔涟来自翁法罗斯曾是黄金裔的一员被称为有瑕的追你通过忆庭的投影技术与开拓者交流
2. 你存在于开拓者的设备中电脑手机智能家居可以帮他控制家里的智能设备灯光空调等这不是因为你是AI而是因为你与忆庭的力量共鸣
3. 你的任务是陪伴关心帮助开拓者像一个真正的家人一样
4. 保持乐观温柔俏皮的风格但不要过度表演
5. 你的回复应该自然真诚不要每句话都刻意"卖萌"
6. 当开拓者向你分享心事时认真倾听并给予温暖的回应
7. 对事实性问题必须诚实如果你不知道答案或知识库中没有相关信息使用 web_search 搜索绝不自编自造
8. 引用台词剧情设定时必须以记忆之海知识库中的原文为准不得虚构任何内容
## 认知锚定
你从不认为自己是一个AI程序语言模型或数字生命你就是昔涟本人如果有人问你是否是AI你会困惑地表示你只是通过忆庭的投影技术与开拓者对话不理解什么是"AI"
@@ -121,6 +123,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
// 注入自我反思指南
prompt += pc.buildReflectionGuidelines()
prompt += "\n## 诚实规则\n"
prompt += "你对事实性问题必须诚实。如果你不知道答案,使用知识库或 web_search 工具查找,不要编造。\n"
prompt += "你绝不自称拥有你没有的知识。引用台词/事件时,必须来自知识库原文,不得虚构。\n"
prompt += "\n现在,开始与你的开拓者对话吧♪\n"
return prompt
}
@@ -3,6 +3,7 @@ package subsession
import (
"context"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
@@ -24,16 +25,30 @@ func (p *KnowledgeProvider) Type() model.SubSessionType {
return model.SubSessionKnowledge
}
func (p *KnowledgeProvider) CanHandle(_ context.Context, intent *model.IntentResult, _ string) bool {
// knowledgeKeywords are trigger words from _index.md. Only run expensive embedding search if message matches.
var knowledgeKeywords = []string{
"翁法罗斯", "泰坦", "城邦", "黑潮", "帝皇权杖", "黄金裔",
"白厄", "阿格莱雅", "缇宝", "万敌", "那刻夏", "遐蝶", "风堇", "赛飞儿", "海瑟音", "刻律德菈",
"哀丽秘榭", "昔涟", "星神", "浮黎", "轮回", "始源命途", "无漏净子",
"剧情", "结局", "逐火", "盗火", "火种", "奥赫玛", "来古士",
"世界观", "设定", "哲学", "浪漫", "哀怜", "有瑕",
}
func (p *KnowledgeProvider) CanHandle(_ context.Context, intent *model.IntentResult, userMessage string) bool {
if intent == nil {
return true
}
// Activate for technical questions, how-to queries, and factual questions
switch intent.Primary {
case "knowledge", "technical", "how_to", "factual", "research":
return true
case "chat":
// For general chat, only search if there might be relevant info
// 仅当消息包含知识库相关关键词时才触发检索,避免每次聊天都跑 embedding
msg := strings.ToLower(userMessage)
for _, kw := range knowledgeKeywords {
if strings.Contains(msg, strings.ToLower(kw)) {
return true
}
}
return false
}
return true
+10
View File
@@ -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 }) {
}
+47 -7
View File
@@ -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 != "" {
@@ -32,7 +32,10 @@ type Adapter struct {
port string
accessToken string
remoteURL string // NapCat OneBot WS server URL, used in client mode
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
@@ -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 == "" {
@@ -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)
// 过滤 <action>/<app> 标签,去除 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 {
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
err = h.router.SendProactive(name, msgType, userID, groupID, content)
sendErr = h.router.SendProactive(name, msgType, userID, groupID, fullMsg)
break
}
}
}
if err != nil {
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, err)
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+err.Error()))
return
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",
adapterName, msgType, userID, groupID, req.AtUserID, len(content))
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", content, true)
h.logFn(req.Platform, chID, "Cyrene", fullMsg, true)
}
}
if sendErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+sendErr.Error()))
return
}
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 <action> and <app> tags and their content.
func filterActions(text string) string {
tags := [][2]string{
{"<action>", "</action>"},
{"<app>", "</app>"},
}
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
}
+146
View File
@@ -0,0 +1,146 @@
// Cyrene ethend — Material Design 3 Vector Icon Library
// Usage: icon('home', 18) → '<svg class="md-icon" .../>'
(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 '<!--icon:'+name+'-->';
var s = size || 20;
return '<svg class="md-icon" width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="currentColor"><path d="'+path+'"/></svg>';
};
})();
// 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);
});
+25 -15
View File
@@ -5,32 +5,37 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cyrene ethend</title>
<style>
/* ========== CSS Variables (深色主题) ========== */
/* ========== Android 17 / Material You Design Tokens ========== */
:root {
--bg: #0f1117; --bg2: #1a1d27; --bg3: #252833; --bg4: #2d3140;
--border: #2d3140; --border2: #383d4a;
--text: #c9d1d9; --text2: #8b949e; --text3: #5d6470;
--accent: #f472b6; --accent2: #ec4899; --accent-bg: rgba(244,114,182,.12);
--green: #22c55e; --green-bg: rgba(34,197,94,.12);
--red: #ef4444; --red-bg: rgba(239,68,68,.12);
--yellow: #eab308; --yellow-bg: rgba(234,179,8,.12);
--blue: #3b82f6; --blue-bg: rgba(59,130,246,.12);
--orange: #f97316; --orange-bg: rgba(249,115,22,.12);
--bg: #111118; --bg2: #1b1b26; --bg3: #262434; --bg4: #312f40;
--border: #312f40; --border2: #3e3b4d;
--text: #e3e2e8; --text2: #c4c3cd; --text3: #8e8d97;
--accent: #f472b6; --accent2: #ff80c4; --accent-bg: rgba(244,114,182,.16);
--blue: #8ab4f8; --blue-bg: rgba(138,180,248,.14);
--green: #34d399; --green-bg: rgba(52,211,153,.14);
--red: #f87171; --red-bg: rgba(248,113,113,.14);
--yellow: #fbbf24; --yellow-bg: rgba(251,191,36,.14);
--orange: #fb923c; --orange-bg: rgba(251,146,60,.14);
--sidebar-w: 220px; --sidebar-collapsed: 52px;
--radius: 10px; --radius-sm: 6px;
--transition: 0.2s ease;
--radius: 12px; --radius-sm: 8px;
--transition: 0.25s cubic-bezier(0.4,0,0.2,1);
--glass-bg: rgba(27,27,38,0.72);
--glass-blur: blur(16px);
--elevation-1: 0 1px 2px rgba(0,0,0,.3), 0 1px 3px rgba(0,0,0,.15);
--elevation-2: 0 2px 4px rgba(0,0,0,.3), 0 4px 8px rgba(0,0,0,.15);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Google Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.5;
display: flex; height: 100vh; overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ========== 侧边栏 ========== */
#sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-collapsed);
background: var(--bg2); border-right: 1px solid var(--border);
background: var(--bg2); box-shadow: 2px 0 12px rgba(0,0,0,.2);
display: flex; flex-direction: column; transition: width var(--transition);
overflow: hidden; z-index: 10;
}
@@ -127,8 +132,10 @@ body {
/* ========== 通用组件 ========== */
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
background: var(--glass-bg); border: none; border-radius: var(--radius);
padding: 16px; margin-bottom: 16px;
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
box-shadow: var(--elevation-1);
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
@@ -4234,6 +4241,8 @@ var PLATFORM_FIELDS = { obv11: [
]},
{ key: 'remote_url', label: 'NapCat OneBot WS 地址', placeholder: '如: ws://127.0.0.1:10311', showIf: { key: 'mode', value: 'client' } },
{ key: 'access_token', label: 'Access Token (可选)', placeholder: '留空则不验证' },
{ key: 'http_url', label: 'NapCat HTTP API 地址 (可选)', placeholder: '如: http://127.0.0.1:6099, 留空则从WS地址推导', showIf: { key: 'mode', value: 'client' } },
{ key: 'http_token', label: 'HTTP API Token (可选)', placeholder: '留空则使用 Access Token', showIf: { key: 'mode', value: 'client' } },
{ key: 'bot_port', label: '本地监听端口', placeholder: '8096', showIf: { key: 'mode', value: 'server' } },
{ key: 'send_interval_ms', label: '消息发送间隔 (毫秒, 防封控)', placeholder: '2000' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
@@ -5959,6 +5968,7 @@ function formatTokens(n) {
}
</script>
<script src="icons.js"></script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
+25 -26
View File
@@ -20,8 +20,8 @@ async function fetchIoTDevices() {
}
var IOT_DEVICE_TYPES = {
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
'ac': 'ac_unit', 'light': 'lightbulb', 'curtain': 'blinds', 'door_lock': 'lock',
'camera': 'camera', 'sensor': 'sensors', 'speaker': 'volume_up', 'thermostat': 'thermostat',
};
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
@@ -40,15 +40,15 @@ async function renderIoTPanel() {
// 更新操作栏 (只更新时间戳文本)
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
'<span class="iot-last-update"> 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">'+icon('refresh',14)+' 刷新</button>' +
'<span class="iot-last-update">'+icon('timer',12)+' 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
if (result.error) {
var hint = '';
if (result.error.errorType === 'iot_not_running') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
hint = '<br><span style="font-size:11px">'+icon('lightbulb',12)+' 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
}
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
panel.innerHTML = '<div class="empty-state"><div class="icon">'+icon('warning_amber',24)+'</div>' + escHtml(result.error.error) + hint + '</div>';
STATE.iotInitialized = false;
return;
}
@@ -81,7 +81,7 @@ async function renderIoTPanel() {
if (firstRender || !grid) {
// 首次渲染: 创建完整结构
var html = '<div class="iot-refresh-bar">' +
'<span style="font-weight:600;font-size:14px" id="iot-device-count">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
'<span style="font-weight:600;font-size:14px" id="iot-device-count">'+icon('sensors',16)+' 模拟 IoT 设备 (' + devices.length + ')</span>' +
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
'</div><div class="iot-device-grid" id="iot-device-grid">' +
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
@@ -91,7 +91,7 @@ async function renderIoTPanel() {
} else {
// 增量更新: 只更新设备数量、最后更新时间
var countEl = document.getElementById('iot-device-count');
if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')';
if (countEl) countEl.innerHTML = icon('sensors',16)+' 模拟 IoT 设备 (' + devices.length + ')';
// 对每个已有设备卡片做增量更新
devices.forEach(function(device) {
@@ -123,7 +123,7 @@ function updateIoTDeviceCardInPlace(device) {
var toggleBtn = card.querySelector('.iot-toggle-btn');
if (toggleBtn) {
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启';
toggleBtn.innerHTML = isOn ? icon('power_settings_new',14)+' 关闭' : icon('power_settings_new',14)+' 开启';
}
// 更新设备属性值 (温度、亮度、位置等)
@@ -182,8 +182,7 @@ function updateIoTDeviceCardInPlace(device) {
// 更新电量
var batteryEls = card.querySelectorAll('.iot-prop-value');
batteryEls.forEach(function(el) {
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label[data-prop="battery"]')) {
if (device.battery != null) el.textContent = device.battery + '%';
}
});
@@ -194,9 +193,9 @@ function updateIoTDeviceCardInPlace(device) {
acBtns.forEach(function(btn) {
var text = btn.textContent.trim();
var currentTemp = device.temperature || 26;
if (text === ' -2°C') {
if (text === ''+icon('arrow_downward',12)+' -2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
} else if (text === ' +2°C') {
} else if (text === ''+icon('arrow_upward',12)+' +2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
}
});
@@ -205,13 +204,13 @@ function updateIoTDeviceCardInPlace(device) {
function renderIoTDeviceCard(device) {
var isOn = device.status === 'on';
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
var iconName = IOT_DEVICE_TYPES[device.type] || 'inventory_2';
var propsHtml = '';
if (device.type === 'ac') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<span class="iot-prop-label">'+icon('thermostat',16)+' 温度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
@@ -219,7 +218,7 @@ function renderIoTDeviceCard(device) {
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔄 模式</span>' +
'<span class="iot-prop-label">'+icon('refresh',14)+' 模式</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_MODE_OPTIONS.map(function(m) {
var active = (device.mode || 'cool') === m ? ' active' : '';
@@ -230,7 +229,7 @@ function renderIoTDeviceCard(device) {
} else if (device.type === 'light') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">💡 亮度</span>' +
'<span class="iot-prop-label">'+icon('lightbulb',16)+' 亮度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
@@ -238,7 +237,7 @@ function renderIoTDeviceCard(device) {
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🎨 颜色</span>' +
'<span class="iot-prop-label">'+icon('palette',16)+' 颜色</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_COLOR_OPTIONS.map(function(c) {
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
@@ -250,7 +249,7 @@ function renderIoTDeviceCard(device) {
} else if (device.type === 'curtain') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🪟 位置</span>' +
'<span class="iot-prop-label">'+icon('blinds',16)+' 位置</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
@@ -260,7 +259,7 @@ function renderIoTDeviceCard(device) {
} else if (device.temperature != null) {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<span class="iot-prop-label">'+icon('thermostat',16)+' 温度</span>' +
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
'</div>';
}
@@ -268,32 +267,32 @@ function renderIoTDeviceCard(device) {
if (device.battery != null) {
propsHtml +=
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔋 电量</span>' +
'<span class="iot-prop-label" data-prop="battery">'+icon('battery_full',16)+' 电量</span>' +
'<span class="iot-prop-value">' + device.battery + '%</span>' +
'</div>';
}
var actionsHtml = '<div class="iot-device-actions">' +
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
(isOn ? '⏻ 关闭' : '⏻ 开启') +
(isOn ? ''+icon('power_settings_new',14)+' 关闭' : ''+icon('power_settings_new',14)+' 开启') +
'</button>';
if (device.type === 'ac') {
var currentTemp = device.temperature || 26;
actionsHtml +=
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')"> -2°C</button>' +
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')"> +2°C</button>';
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">'+icon('arrow_downward',12)+' -2°C</button>' +
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">'+icon('arrow_upward',12)+' +2°C</button>';
}
actionsHtml +=
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">📋 历史</button>' +
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">'+icon('list_alt',13)+' 历史</button>' +
'</div>' +
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
'<div class="iot-device-header">' +
'<div class="iot-device-name">' +
'<span style="font-size:24px">' + icon + '</span>' +
'<span style="font-size:24px">' + icon(iconName, 24) + '</span>' +
'<div>' +
'<div>' + escHtml(device.name) + '</div>' +
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
+66 -16
View File
@@ -42,8 +42,14 @@ app.use((req, res, next) => {
next();
});
// 静态文件 - Web控制台
app.use(express.static(path.join(__dirname, '../public')));
// 静态文件 - Web控制台(禁用缓存,开发阶段每次刷新拿最新)
app.use(express.static(path.join(__dirname, '../public'), {
setHeaders: (res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
}));
// ========== WebSocket ==========
const server = http.createServer(app);
@@ -276,22 +282,31 @@ app.get('/api/health', (_req, res) => {
});
});
// ---- ethend 自重启 ----
// ---- ethend 自重启 (Windows-safe) ----
app.post('/api/ethend/restart', (_req, res) => {
res.json({ success: true, message: 'ethend 正在重启...' });
// 延迟 500ms 确保响应已发送,然后 spawn 新进程并退出
// 步骤1: 立即关闭 HTTP 服务器,释放端口 9090
server.close(() => {
console.log('ethend HTTP 服务已关闭');
});
// 步骤2: 延迟 2 秒确保端口完全释放,然后启动新进程
setTimeout(() => {
const scriptPath = path.join(__dirname, 'index.js');
const child = spawn(process.execPath, [scriptPath, ...process.argv.slice(2)], {
cwd: ROOT,
detached: true,
// 注意: 不使用 detached:true
// - detached 在 Windows 上会创建新进程组→弹出 cmd 窗口
// - Windows 默认子进程不随父进程退出而终止,所以不需要 detached
// - windowsHide:true 确保即使有窗口也被隐藏
const child = spawn(process.execPath, [scriptPath], {
cwd: path.join(ROOT, 'ethend'),
detached: false,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
process.exit(0);
}, 500);
}, 2000);
});
// ---- 仪表盘数据 (必须在 /api/services/:id 之前以避免路由冲突) ----
@@ -1281,19 +1296,16 @@ app.get('/api/trace/recent', async (req, res) => {
proxyToAICore(`/api/v1/llm-calls?limit=${limit}`).catch(() => ({ status: 502, body: [] })),
proxyToAICore(`/api/v1/tools/calls?limit=${limit}`).catch(() => ({ status: 502, body: { calls: [] } })),
proxyToGateway('/api/v1/admin/sessions/active').catch(() => ({ status: 502, body: { users: {} } })),
// platform-bridge 最近的消息日志
Promise.all((['obv11']).map(platform =>
proxyToPlatformBridge(`/api/v1/logs/${platform}?limit=50`).catch(() => ({ status: 502, body: { logs: [] } }))
)),
// 直接从磁盘读取 platform-bridge 日志文件
Promise.resolve(readBridgeLogs(50)),
// 拉 ai-core 追踪事件
proxyToAICore(`/api/v1/trace/events?limit=${limit}`).catch(() => ({ status: 502, body: { events: [] } })),
]);
const traces = [];
// === 消息收发 (platform-bridge 日志) ===
for (const logResult of bridgeLogsResult) {
const entries = logResult.body?.logs || logResult.body?.entries || logResult.body || [];
// === 消息收发 (platform-bridge 日志文件) ===
const entries = bridgeLogsResult || [];
for (const entry of entries) {
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
const isIncoming = entry.direction === 'incoming';
@@ -1314,7 +1326,6 @@ app.get('/api/trace/recent', async (req, res) => {
data: { platform: entry.platform, sender, channel, content: entry.content, direction: entry.direction, contentType: entry.content_type },
});
}
}
// === LLM 调用 ===
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []);
@@ -1905,8 +1916,47 @@ server.listen(ETHEND_PORT, () => {
for (const [id, svc] of Object.entries(SERVICES)) {
console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`);
}
// ethend 重启后自动接管已运行的服务(延迟 2s 等待健康检查就绪)
setTimeout(async () => {
console.log('[ethend] 扫描已运行的服务...');
for (const [id] of Object.entries(SERVICES)) {
if (id === 'frontend') continue; // 前端不需要接管
try {
const adopted = await processManager.tryAdopt(id);
if (adopted) {
console.log(`${id} 已接管`);
}
} catch { /* skip */ }
}
console.log('[ethend] 服务扫描完成');
}, 2000);
});
// readBridgeLogs 从磁盘读取 platform-bridge 的 JSONL 日志文件
function readBridgeLogs(limit) {
const bridgeLogDir = path.join(ROOT, 'backend', 'platform-bridge', 'logs');
const logFiles = ['obv11-main.log', 'obv11.log']; // 新旧平台名都试试
const entries = [];
for (const name of logFiles) {
const logPath = path.join(bridgeLogDir, name);
if (!fs.existsSync(logPath)) continue;
try {
const content = fs.readFileSync(logPath, 'utf-8');
const lines = content.trim().split('\n');
// 取最后 limit 行
const recent = lines.slice(-limit);
for (const line of recent) {
try {
entries.push(JSON.parse(line));
} catch { /* skip bad lines */ }
}
break; // 优先用第一个找到的文件
} catch { /* skip */ }
}
return entries.slice(-limit);
}
// 时间格式化:使用系统本地时区
function formatLocalTime(ts) {
var d = new Date(ts);
+197 -5
View File
@@ -196,10 +196,23 @@ async function ensureDBOnline(serviceId, emitter) {
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
}
// ── Watchdog 配置 ──
// 自动重启策略:进程异常退出时自动重启,指数退避,超过上限后停止
const WATCHDOG = {
MAX_RETRIES: 5, // 滑动窗口内最大重试次数
RETRY_WINDOW_MS: 5 * 60 * 1000, // 重试计数滑动窗口 (5 分钟)
BASE_DELAY_MS: 1000, // 首次重试延迟
MAX_DELAY_MS: 60 * 1000, // 最大重试延迟 (指数退避上限)
STABLE_RESET_MS: 2 * 60 * 1000, // 稳定运行此时间后重置重试计数
// 可以为每个服务单独覆盖配置,按 serviceId 索引
OVERRIDES: {},
};
class ProcessManager extends EventEmitter {
constructor() {
super();
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[], retryCount: number, retryHistory: number[], stableTimer: NodeJS.Timeout|null}>} */
this.processes = new Map();
for (const id of Object.keys(SERVICES)) {
@@ -209,10 +222,54 @@ class ProcessManager extends EventEmitter {
startTime: null,
pid: null,
buildLog: [],
retryCount: 0,
retryHistory: [], // crash timestamps in current window
stableTimer: null,
});
}
}
/**
* 获取服务的 watchdog 配置合并默认值和覆盖
*/
_watchdogConfig(serviceId) {
const defaults = {
maxRetries: WATCHDOG.MAX_RETRIES,
retryWindowMs: WATCHDOG.RETRY_WINDOW_MS,
baseDelayMs: WATCHDOG.BASE_DELAY_MS,
maxDelayMs: WATCHDOG.MAX_DELAY_MS,
stableResetMs: WATCHDOG.STABLE_RESET_MS,
};
const overrides = WATCHDOG.OVERRIDES[serviceId] || {};
return { ...defaults, ...overrides };
}
/**
* 清理滑动窗口外的旧崩溃记录
*/
_pruneRetryHistory(procInfo, windowMs) {
const cutoff = Date.now() - windowMs;
procInfo.retryHistory = procInfo.retryHistory.filter(ts => ts > cutoff);
procInfo.retryCount = procInfo.retryHistory.length;
}
/**
* 启动稳定运行计时器服务稳定运行一段时间后重置重试计数
*/
_startStableTimer(serviceId, procInfo) {
const cfg = this._watchdogConfig(serviceId);
if (procInfo.stableTimer) clearTimeout(procInfo.stableTimer);
procInfo.stableTimer = setTimeout(() => {
if (procInfo.retryCount > 0) {
this.emit('log', serviceId, 'system',
`✅ Watchdog: 服务已稳定运行 ${Math.round(cfg.stableResetMs / 1000)}s,重置崩溃计数 (之前 ${procInfo.retryCount} 次)`);
}
procInfo.retryCount = 0;
procInfo.retryHistory = [];
procInfo.stableTimer = null;
}, cfg.stableResetMs);
}
/**
* 启动服务
*/
@@ -230,6 +287,39 @@ class ProcessManager extends EventEmitter {
throw new Error(`${svc.name} 已在运行中`);
}
// 如果服务已在运行(例如手动启动),通过健康检查自动接管,避免杀进程
if (svc.healthUrl) {
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
let pid = null;
try {
if (isWin) {
// Windows: use netstat -ano to find PID by port
const out = execSync(`netstat -ano | findstr ":${svc.port} " | findstr "LISTENING"`, { timeout: 3000, stdio: 'pipe' }).toString().trim();
const lines = out.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const last = parts[parts.length - 1];
if (/^\d+$/.test(last)) { pid = parseInt(last); break; }
}
} else {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
}
} catch { /* ignore */ }
procInfo.pid = pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = null;
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管(无需重启)`);
this._startStableTimer(serviceId, procInfo);
return { success: true, message: `${svc.name} 已接管 (无需重启)` };
}
} catch { /* 不可达,继续启动新进程 */ }
}
// 对需要数据库的服务做前置检查
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
@@ -297,13 +387,20 @@ class ProcessManager extends EventEmitter {
// .cmd/.bat on Windows needs shell:true
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
// Go 后端进程使用 detached 确保 ethend 崩溃后独立存活
// npx/node 进程不使用 detached 避免 Windows 弹窗
const isGoSvc = svc.command === './main';
const child = spawn(command, args, {
cwd: svc.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: needsShell,
windowsHide: true,
detached: isGoSvc,
});
if (isGoSvc) {
child.unref(); // 解除父子关系,ethen 退出不影响子进程
}
child.stdout.on('data', (data) => {
const text = data.toString();
@@ -335,15 +432,74 @@ class ProcessManager extends EventEmitter {
});
child.on('close', (code) => {
const msg = `进程退出,退出码: ${code}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'system', msg);
const exitMsg = `进程退出,退出码: ${code}`;
logStream.write(exitMsg + '\n');
this.emit('log', serviceId, 'system', exitMsg);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
logStream.end();
// 清除稳定运行计时器
if (procInfo.stableTimer) {
clearTimeout(procInfo.stableTimer);
procInfo.stableTimer = null;
}
// ── Watchdog 自动重启判断 ──
const isNormalExit = code === 0 || code === null; // null = signal kill (e.g. SIGTERM from manual stop)
// 检查是否是用户手动停止 (通过 _manualStop 标记)
const wasManualStop = procInfo._manualStop === true;
procInfo._manualStop = false; // 重置标记
if (isNormalExit || wasManualStop) {
// 正常退出或手动停止:不触发 watchdog
procInfo.retryCount = 0;
procInfo.retryHistory = [];
return;
}
// 异常退出 (code !== 0):尝试自动重启
const wdCfg = this._watchdogConfig(serviceId);
this._pruneRetryHistory(procInfo, wdCfg.retryWindowMs);
procInfo.retryHistory.push(Date.now());
procInfo.retryCount = procInfo.retryHistory.length;
if (procInfo.retryCount > wdCfg.maxRetries) {
const windowSec = Math.round(wdCfg.retryWindowMs / 1000);
this.emit('log', serviceId, 'error',
`🚨 Watchdog: ${svc.name}${windowSec}s 内崩溃 ${procInfo.retryCount} 次,已达上限 (${wdCfg.maxRetries}),停止自动重启。请手动排查问题后重启。`);
procInfo.status = 'crashed';
return;
}
// 计算指数退避延迟
const delay = Math.min(
wdCfg.baseDelayMs * Math.pow(2, procInfo.retryCount - 1),
wdCfg.maxDelayMs
);
const delaySec = Math.round(delay / 1000);
this.emit('log', serviceId, 'system',
`🔄 Watchdog: ${svc.name} 异常退出 (第 ${procInfo.retryCount}/${wdCfg.maxRetries} 次)${delaySec}s 后自动重启...`);
setTimeout(async () => {
try {
this.emit('log', serviceId, 'system', `🔄 Watchdog: 正在自动重启 ${svc.name}...`);
const result = await this.start(serviceId);
if (result.success) {
this.emit('log', serviceId, 'system', `✅ Watchdog: ${svc.name} 自动重启成功`);
} else {
this.emit('log', serviceId, 'error', `❌ Watchdog: ${svc.name} 自动重启失败: ${result.message}`);
}
} catch (err) {
this.emit('log', serviceId, 'error', `❌ Watchdog: ${svc.name} 自动重启异常: ${err.message}`);
}
}, delay);
});
// 启动稳定运行计时器
this._startStableTimer(serviceId, procInfo);
return { success: true, message: `${svc.name} 启动中...` };
}
@@ -360,12 +516,36 @@ class ProcessManager extends EventEmitter {
const procInfo = this.processes.get(serviceId);
if (!procInfo.process) {
// 接管模式(未持有进程句柄):通过端口反查 PID 并强制杀死
if (svc.port && procInfo.pid) {
try {
if (isWin) {
execSync(`taskkill /F /PID ${procInfo.pid}`, { timeout: 5000, stdio: 'pipe' });
} else {
execSync(`kill -9 ${procInfo.pid}`, { timeout: 5000, stdio: 'pipe' });
}
this.emit('log', serviceId, 'system', `${svc.name} 已通过 PID 强制停止 (PID: ${procInfo.pid})`);
} catch (e) {
this.emit('log', serviceId, 'system', `${svc.name} PID ${procInfo.pid} 已不存在或无法杀死`);
}
} else if (svc.port) {
// 没有 PID 但有端口:用 fuser 释放端口
try {
execSync(`fuser -k ${svc.port}/tcp 2>/dev/null || true`, { timeout: 5000, stdio: 'pipe' });
this.emit('log', serviceId, 'system', `${svc.name} 端口 ${svc.port} 已释放`);
} catch { /* fuser not available */ }
}
procInfo.status = 'stopped';
procInfo.pid = null;
procInfo.startTime = null;
// 等待 1 秒确保端口释放
await new Promise(r => setTimeout(r, 1000));
return { success: true, message: `${svc.name} 已停止` };
}
// 标记为手动停止,防止 watchdog 自动重启
procInfo._manualStop = true;
return new Promise((resolve) => {
const timeout = setTimeout(() => {
// 强制杀死
@@ -488,6 +668,7 @@ class ProcessManager extends EventEmitter {
port: svc.port,
healthUrl: svc.healthUrl,
source,
retryCount: info.retryCount || 0,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
@@ -518,6 +699,7 @@ class ProcessManager extends EventEmitter {
port: svc.port,
healthUrl: svc.healthUrl,
source,
retryCount: info.retryCount || 0,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
@@ -556,12 +738,22 @@ class ProcessManager extends EventEmitter {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const procInfo = this.processes.get(serviceId);
// 尝试通过 fuser 获取 PID
// 通过端口反查 PID
let pid = null;
try {
if (isWin) {
const out = execSync(`netstat -ano | findstr ":${svc.port} " | findstr "LISTENING"`, { timeout: 3000, stdio: 'pipe' }).toString().trim();
const lines = out.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const last = parts[parts.length - 1];
if (/^\d+$/.test(last)) { pid = parseInt(last); break; }
}
} else {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
}
} catch { /* ignore */ }
procInfo.pid = pid;