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:
@@ -11,6 +11,9 @@ dist/
|
|||||||
# ========== 子仓库 ==========
|
# ========== 子仓库 ==========
|
||||||
backend/cyrene-plugins/
|
backend/cyrene-plugins/
|
||||||
|
|
||||||
|
# ========== 用户插件(独立项目,不进主仓库) ==========
|
||||||
|
backend/plugins/
|
||||||
|
|
||||||
# ========== Go 编译二进制 ==========
|
# ========== Go 编译二进制 ==========
|
||||||
backend/ai-core/main
|
backend/ai-core/main
|
||||||
backend/ai-core/cmd/main
|
backend/ai-core/cmd/main
|
||||||
|
|||||||
@@ -1,38 +1,127 @@
|
|||||||
//go:build ignore
|
//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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PluginEntry struct {
|
type PluginEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Import string `json:"import"`
|
Import string `json:"import,omitempty"` // built-in: explicit import
|
||||||
Struct string `json:"struct"`
|
Struct string `json:"struct"`
|
||||||
Constructor string `json:"constructor,omitempty"`
|
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"`
|
Version string `json:"version"`
|
||||||
Plugins []PluginEntry `json:"plugins"`
|
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 {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "parse plugins.json: %v\n", err)
|
return nil, fmt.Errorf("parse plugins.json: %w", err)
|
||||||
os.Exit(1)
|
}
|
||||||
|
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
|
var sb strings.Builder
|
||||||
sb.WriteString("// Code generated by gen_plugins.go; DO NOT EDIT.\n\n")
|
sb.WriteString("// Code generated by gen_plugins.go; DO NOT EDIT.\n\n")
|
||||||
sb.WriteString("package main\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")
|
sb.WriteString("\tplgSDK \"git.yeij.top/AskaEth/Cyrene-Plugins/sdk\"\n")
|
||||||
|
|
||||||
aliases := make(map[string]string)
|
aliases := make(map[string]string)
|
||||||
for _, p := range cfg.Plugins {
|
imported := make(map[string]bool)
|
||||||
alias := p.Name + "pkg"
|
for _, p := range plugins {
|
||||||
aliases[p.Name] = alias
|
pkgAlias := p.Name + "pkg"
|
||||||
sb.WriteString(fmt.Sprintf("\t%s \"%s\"\n", alias, p.Import))
|
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(")\n\n")
|
||||||
sb.WriteString("func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) {\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]
|
alias := aliases[p.Name]
|
||||||
if p.Constructor != "" {
|
if p.Constructor != "" {
|
||||||
// Plugins with constructor (e.g. NewFilePlugin(dataDir))
|
|
||||||
if p.Name == "file_ops" || p.Name == "http_request" {
|
if p.Name == "file_ops" || p.Name == "http_request" {
|
||||||
sb.WriteString(fmt.Sprintf("\tfor _, t := range %s.%s(nil).Tools() {\n", alias, p.Constructor))
|
sb.WriteString(fmt.Sprintf("\tfor _, t := range %s.%s(nil).Tools() {\n", alias, p.Constructor))
|
||||||
} else {
|
} else {
|
||||||
@@ -69,8 +161,89 @@ func main() {
|
|||||||
|
|
||||||
outPath := "plugins_gen.go"
|
outPath := "plugins_gen.go"
|
||||||
if err := os.WriteFile(outPath, []byte(sb.String()), 0644); err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Printf("Generated %s with %d plugins\n", outPath, len(cfg.Plugins))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-9
@@ -9,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
|
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
|
||||||
aiConfig "git.yeij.top/AskaEth/Cyrene/ai-core/internal/config"
|
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"
|
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/host"
|
||||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
|
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
|
||||||
@@ -448,15 +450,78 @@ func main() {
|
|||||||
)
|
)
|
||||||
orch.SetToolRegistry(toolRegistry)
|
orch.SetToolRegistry(toolRegistry)
|
||||||
// 设置工具结果主动推送回调 — 通用,不绑定特定工具
|
// 设置工具结果主动推送回调 — 通用,不绑定特定工具
|
||||||
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string) {
|
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string, params orchestrator.SynthesizeParams) {
|
||||||
if thinker == nil {
|
if thinker == nil || orch == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 通过 thinker 的主动消息机制推送
|
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = adminUserID
|
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 {
|
if visionProvider != nil {
|
||||||
@@ -593,6 +658,21 @@ func main() {
|
|||||||
w.Write([]byte(`{"status":"ok"}`))
|
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) {
|
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
|
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("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
@@ -714,10 +795,20 @@ func main() {
|
|||||||
json.NewEncoder(w).Encode(result)
|
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{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: mux,
|
Handler: debugMux,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
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)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -1013,7 +1119,8 @@ func handleChat(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Inject admin flag for tool access control.
|
// Inject admin flag for tool access control.
|
||||||
ctx = context.WithValue(ctx, plgManager.CtxKeyIsAdmin, req.IsAdmin)
|
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
|
// Admin private messages: redirect to the main admin session so conversation
|
||||||
// history is shared across platforms (OBv11, web UI, etc.).
|
// 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
|
req.SessionID = adminSessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,6 +1158,9 @@ func handleChat(
|
|||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
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)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
||||||
@@ -1058,7 +1168,12 @@ func handleChat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 缓存用户消息到会话历史(在 Orchestrator 之前,确保顺序正确:user → assistant)
|
// 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 处理(替代原有的线性处理流程)
|
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
|
||||||
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
|
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func GetTraceEvents(sessionID string, limit int) []TraceEvent {
|
|||||||
if limit <= 0 || limit > len(traceEvents) {
|
if limit <= 0 || limit > len(traceEvents) {
|
||||||
limit = len(traceEvents)
|
limit = len(traceEvents)
|
||||||
}
|
}
|
||||||
var result []TraceEvent
|
result := make([]TraceEvent, 0)
|
||||||
// Return newest first.
|
// Return newest first.
|
||||||
for i := len(traceEvents) - 1; i >= 0 && len(result) < limit; i-- {
|
for i := len(traceEvents) - 1; i >= 0 && len(result) < limit; i-- {
|
||||||
ev := traceEvents[i]
|
ev := traceEvents[i]
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
|
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
|
||||||
@@ -125,6 +127,9 @@ func ParsePlatformChannels(raw string) []PlatformChannel {
|
|||||||
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)。
|
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)。
|
||||||
type Thinker struct {
|
type Thinker struct {
|
||||||
mu sync.Mutex
|
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
|
wg sync.WaitGroup
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
|
|
||||||
@@ -198,11 +203,11 @@ type Thinker struct {
|
|||||||
pendingThoughts []*PendingThought
|
pendingThoughts []*PendingThought
|
||||||
lastUserMessage time.Time
|
lastUserMessage time.Time
|
||||||
lastThinkTime time.Time
|
lastThinkTime time.Time
|
||||||
lastProactiveMsgTime time.Time
|
lastProactiveMsgTime time.Time // deprecated, use lastProactiveTime()
|
||||||
thinkCancel context.CancelFunc // 取消当前思考,让步于前台
|
thinkCancel context.CancelFunc // 取消当前思考,让步于前台
|
||||||
|
|
||||||
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
|
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
|
||||||
thinkCount int
|
thinkCountAtomic atomic.Int64
|
||||||
|
|
||||||
// Phase 1 Step 4: 思考链 + 自主工具安全策略
|
// Phase 1 Step 4: 思考链 + 自主工具安全策略
|
||||||
chain *ThinkChain
|
chain *ThinkChain
|
||||||
@@ -218,7 +223,10 @@ type Thinker struct {
|
|||||||
scheduleLoader *ScheduleLoader
|
scheduleLoader *ScheduleLoader
|
||||||
|
|
||||||
// Phase 2: 在线状态追踪
|
// 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
|
lastOnlineChange time.Time
|
||||||
userSessionID string // 当前活跃的 session ID (用于重连)
|
userSessionID string // 当前活跃的 session ID (用于重连)
|
||||||
|
|
||||||
@@ -284,28 +292,28 @@ func timePeriod(now time.Time) (string, string) {
|
|||||||
// SetMessagePusher 设置主动消息推送回调
|
// SetMessagePusher 设置主动消息推送回调
|
||||||
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
|
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
|
||||||
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
|
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
t.scheduleLoader = loader
|
t.scheduleLoader = loader
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
t.messagePusher = pusher
|
t.messagePusher = pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (OBv11, etc.).
|
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (OBv11, etc.).
|
||||||
func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) {
|
func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
t.platformMessagePusher = pusher
|
t.platformMessagePusher = pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBotUID sets the bot's own platform UID (e.g., OBv11 account).
|
// SetBotUID sets the bot's own platform UID (e.g., OBv11 account).
|
||||||
func (t *Thinker) SetBotUID(platform, uid string) {
|
func (t *Thinker) SetBotUID(platform, uid string) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
if t.botUIDs == nil {
|
if t.botUIDs == nil {
|
||||||
t.botUIDs = make(map[string]string)
|
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.
|
// AddOrUpdatePlatformChannel adds or updates a platform channel with resolved display name.
|
||||||
func (t *Thinker) AddOrUpdatePlatformChannel(platform, channelType, channelID, channelName, adapterID, adapterName string) {
|
func (t *Thinker) AddOrUpdatePlatformChannel(platform, channelType, channelID, channelName, adapterID, adapterName string) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
|
|
||||||
for i, ch := range t.platformChannels {
|
for i, ch := range t.platformChannels {
|
||||||
if ch.Platform == platform && ch.ChannelType == channelType && ch.ChannelID == channelID {
|
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.
|
// SetEmotionTracker sets the emotion tracker.
|
||||||
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
|
func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
t.emotionTracker = et
|
t.emotionTracker = et
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePresence updates the user online status.
|
// UpdatePresence updates the user online status.
|
||||||
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
|
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
|
||||||
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
wasOffline := !t.userOnline
|
wasOffline := !t.isUserOnline()
|
||||||
t.userOnline = online
|
t.setUserOnline(online)
|
||||||
t.lastOnlineChange = time.Now()
|
t.lastOnlineChange = time.Now()
|
||||||
if sessionID != "" {
|
if sessionID != "" {
|
||||||
t.userSessionID = sessionID
|
t.userSessionID = sessionID
|
||||||
t.activeSessionID = sessionID
|
t.activeSessionID = sessionID
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
if online && wasOffline {
|
if online && wasOffline {
|
||||||
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
|
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.
|
// TriggerReminderMessage pushes a reminder message generated by LLM to the user.
|
||||||
// Called by the internal reminder-trigger endpoint when a reminder fires.
|
// Called by the internal reminder-trigger endpoint when a reminder fires.
|
||||||
func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
pusher := t.messagePusher
|
pusher := t.messagePusher
|
||||||
pushSessionID := sessionID
|
pushSessionID := sessionID
|
||||||
if pushSessionID == "" {
|
if pushSessionID == "" {
|
||||||
@@ -390,7 +398,7 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
|||||||
if pushSessionID == "" {
|
if pushSessionID == "" {
|
||||||
pushSessionID = t.adminSessionID
|
pushSessionID = t.adminSessionID
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
if pusher != nil && message != "" {
|
if pusher != nil && message != "" {
|
||||||
log.Printf("[提醒推送] 推送LLM提醒: user=%s session=%s msg=%s", userID, pushSessionID, 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.
|
// PushPlatformMessage pushes a message to a platform channel via the platform pusher.
|
||||||
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
|
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
pusher := t.platformMessagePusher
|
pusher := t.platformMessagePusher
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if pusher != nil {
|
if pusher != nil {
|
||||||
log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message)
|
log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message)
|
||||||
pusher(target, 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.
|
// IsUserRecentlyActive returns true if the user has been active within the given duration.
|
||||||
func (t *Thinker) IsUserRecentlyActive(d time.Duration) bool {
|
func (t *Thinker) IsUserRecentlyActive(d time.Duration) bool {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
return time.Since(t.lastUserMessage) < d
|
return time.Since(t.lastUserTime()) < d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -532,7 +540,7 @@ func (t *Thinker) restoreContext() {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
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{
|
memories, err := t.memClient.Query(ctx, model.MemoryQuery{
|
||||||
UserID: t.adminUserID,
|
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.
|
// Only use if reasonably recent (within 24h). Old memories don't represent user activity.
|
||||||
if !latest.IsZero() && time.Since(latest) < 24*time.Hour {
|
if !latest.IsZero() && time.Since(latest) < 24*time.Hour {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
t.lastUserMessage = latest
|
t.lastUserMessage = latest
|
||||||
t.mu.Unlock()
|
t.setLastUser(latest)
|
||||||
|
t.muUnlock()
|
||||||
log.Printf("[后台思考] 上下文已恢复: 最近活动 %v 前", time.Since(latest).Round(time.Second))
|
log.Printf("[后台思考] 上下文已恢复: 最近活动 %v 前", time.Since(latest).Round(time.Second))
|
||||||
} else if !latest.IsZero() {
|
} else if !latest.IsZero() {
|
||||||
log.Printf("[后台思考] 上下文恢复跳过: 最近记忆 %v 前(>24h),使用默认值", time.Since(latest).Round(time.Second))
|
log.Printf("[后台思考] 上下文恢复跳过: 最近记忆 %v 前(>24h),使用默认值", time.Since(latest).Round(time.Second))
|
||||||
@@ -573,6 +582,10 @@ func (t *Thinker) Start() {
|
|||||||
|
|
||||||
// 恢复上下文:从持久化存储查询最近活动时间,避免重启后"失忆"
|
// 恢复上下文:从持久化存储查询最近活动时间,避免重启后"失忆"
|
||||||
t.restoreContext()
|
t.restoreContext()
|
||||||
|
// 同步原子字段(restoreContext 写入 lastUserMessage 后需要同步)
|
||||||
|
t.setLastUser(t.lastUserMessage)
|
||||||
|
t.setLastThink(t.lastThinkTime)
|
||||||
|
t.setLastProactive(t.lastProactiveMsgTime)
|
||||||
|
|
||||||
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
|
// 初始化静默检测定时器(但不启动,等第一次用户消息后启动)
|
||||||
if t.silenceTimeout > 0 {
|
if t.silenceTimeout > 0 {
|
||||||
@@ -628,14 +641,16 @@ func (t *Thinker) Stop() {
|
|||||||
// 2. 记录当前活跃的前端会话 ID(用于对话上下文检索和主动消息推送)
|
// 2. 记录当前活跃的前端会话 ID(用于对话上下文检索和主动消息推送)
|
||||||
// 3. 重置静默检测的一次性定时器(如果启用)
|
// 3. 重置静默检测的一次性定时器(如果启用)
|
||||||
func (t *Thinker) RecordUserMessage(sessionID string) {
|
func (t *Thinker) RecordUserMessage(sessionID string) {
|
||||||
t.mu.Lock()
|
now := time.Now()
|
||||||
t.lastUserMessage = time.Now()
|
t.recordMu.Lock()
|
||||||
|
t.lastUserMessage = now
|
||||||
|
t.setLastUser(now)
|
||||||
if sessionID != "" {
|
if sessionID != "" {
|
||||||
t.activeSessionID = sessionID
|
t.activeSessionID = sessionID
|
||||||
}
|
}
|
||||||
// 用户主动发消息时重置主动消息推送冷却——活跃对话中应允许昔涟回复
|
|
||||||
t.lastProactiveMsgTime = time.Time{}
|
t.lastProactiveMsgTime = time.Time{}
|
||||||
t.mu.Unlock()
|
t.setLastProactive(time.Time{})
|
||||||
|
t.recordMu.Unlock()
|
||||||
if t.thinkCancel != nil {
|
if t.thinkCancel != nil {
|
||||||
t.thinkCancel()
|
t.thinkCancel()
|
||||||
t.thinkCancel = nil
|
t.thinkCancel = nil
|
||||||
@@ -656,12 +671,12 @@ func (t *Thinker) TriggerPostChatThink() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
|
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
if !canThink {
|
if !canThink {
|
||||||
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTime), t.minThinkGap)
|
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v)", time.Since(t.lastThinkTimeAtomic()), t.minThinkGap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,10 +740,10 @@ func (t *Thinker) resetSilenceTimer() {
|
|||||||
return
|
return
|
||||||
case <-t.silenceTimer.C:
|
case <-t.silenceTimer.C:
|
||||||
// 再次检查:用户是否真的沉默了足够久
|
// 再次检查:用户是否真的沉默了足够久
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
silenceDuration := time.Since(t.lastUserMessage)
|
silenceDuration := time.Since(t.lastUserTime())
|
||||||
canThink := time.Since(t.lastThinkTime) >= t.minThinkGap
|
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
if silenceDuration < t.silenceTimeout {
|
if silenceDuration < t.silenceTimeout {
|
||||||
log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration)
|
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)
|
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{
|
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||||
Content: observationContent,
|
Content: observationContent,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -851,7 +866,7 @@ func (t *Thinker) performPlatformObservation() {
|
|||||||
if len(t.pendingThoughts) > 10 {
|
if len(t.pendingThoughts) > 10 {
|
||||||
t.pendingThoughts = t.pendingThoughts[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)
|
log.Printf("[后台思考] 平台观察摘要已生成 (长度=%d, 需要关注=%v)", len(result.Summary), result.NeedsAttention)
|
||||||
}
|
}
|
||||||
@@ -874,10 +889,10 @@ func (t *Thinker) lightThinkLoop() {
|
|||||||
log.Println("[后台思考] 轻量思考已停止")
|
log.Println("[后台思考] 轻量思考已停止")
|
||||||
return
|
return
|
||||||
case <-time.After(t.lightThinkInterval):
|
case <-time.After(t.lightThinkInterval):
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
sinceLastUser := time.Since(t.lastUserMessage)
|
sinceLastUser := time.Since(t.lastUserTime())
|
||||||
sinceLastThink := time.Since(t.lastThinkTime)
|
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
// Skip if user was active recently (last 30s).
|
// Skip if user was active recently (last 30s).
|
||||||
if sinceLastUser < 30*time.Second {
|
if sinceLastUser < 30*time.Second {
|
||||||
@@ -895,6 +910,8 @@ func (t *Thinker) lightThinkLoop() {
|
|||||||
|
|
||||||
// performLightThink runs a single lightweight thinking cycle using the fast model.
|
// performLightThink runs a single lightweight thinking cycle using the fast model.
|
||||||
func (t *Thinker) performLightThink() {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -933,9 +950,9 @@ func (t *Thinker) performLightThink() {
|
|||||||
// Check if deep think should be woken.
|
// Check if deep think should be woken.
|
||||||
if strings.Contains(content, "【需要深思】") {
|
if strings.Contains(content, "【需要深思】") {
|
||||||
log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...")
|
log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...")
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
canDeep := time.Since(t.lastThinkTime) >= t.minThinkGap
|
canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if canDeep {
|
if canDeep {
|
||||||
t.performThink("light_wake")
|
t.performThink("light_wake")
|
||||||
} else {
|
} else {
|
||||||
@@ -948,14 +965,14 @@ func (t *Thinker) performLightThink() {
|
|||||||
topic := strings.TrimSpace(content[idx+len("【话题发起】"):])
|
topic := strings.TrimSpace(content[idx+len("【话题发起】"):])
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
log.Printf("[轻量思考] 话题发起: %s", topic)
|
log.Printf("[轻量思考] 话题发起: %s", topic)
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
pusher := t.messagePusher
|
pusher := t.messagePusher
|
||||||
sessionID := t.activeSessionID
|
sessionID := t.activeSessionID
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
sessionID = t.adminSessionID
|
sessionID = t.adminSessionID
|
||||||
}
|
}
|
||||||
canPush := time.Since(t.lastProactiveMsgTime) >= t.proactiveMsgMinGap
|
canPush := time.Since(t.lastProactiveTime()) >= t.proactiveMsgMinGap
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if pusher != nil && canPush {
|
if pusher != nil && canPush {
|
||||||
go pusher(t.adminUserID, sessionID, topic)
|
go pusher(t.adminUserID, sessionID, topic)
|
||||||
if t.convStore != nil && sessionID != "" {
|
if t.convStore != nil && sessionID != "" {
|
||||||
@@ -995,15 +1012,10 @@ func (t *Thinker) periodicThinkLoop() {
|
|||||||
log.Println("[后台思考] 周期性思考已停止")
|
log.Println("[后台思考] 周期性思考已停止")
|
||||||
return
|
return
|
||||||
case <-time.After(interval):
|
case <-time.After(interval):
|
||||||
t.mu.Lock()
|
// (atomic fields, no lock needed — all reads are lock-free)
|
||||||
sinceLastThink := time.Since(t.lastThinkTime)
|
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
|
||||||
sinceLastUser := time.Since(t.lastUserMessage)
|
sinceLastUser := time.Since(t.lastUserTime())
|
||||||
t.mu.Unlock()
|
isOffline := !t.isUserOnline()
|
||||||
|
|
||||||
// 离线时降低思考频率(可配置,默认 10 分钟)
|
|
||||||
t.mu.Lock()
|
|
||||||
isOffline := !t.userOnline
|
|
||||||
t.mu.Unlock()
|
|
||||||
offlineMinGap := t.offlineThinkGap
|
offlineMinGap := t.offlineThinkGap
|
||||||
|
|
||||||
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
|
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
|
||||||
@@ -1022,7 +1034,7 @@ func (t *Thinker) periodicThinkLoop() {
|
|||||||
continue
|
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")
|
t.performThink("periodic")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1030,8 +1042,8 @@ func (t *Thinker) periodicThinkLoop() {
|
|||||||
|
|
||||||
// GetPendingThoughts 获取并消费所有待处理的后台思考
|
// GetPendingThoughts 获取并消费所有待处理的后台思考
|
||||||
func (t *Thinker) GetPendingThoughts() []*PendingThought {
|
func (t *Thinker) GetPendingThoughts() []*PendingThought {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
|
|
||||||
if len(t.pendingThoughts) == 0 {
|
if len(t.pendingThoughts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -1048,8 +1060,8 @@ func (t *Thinker) GetPendingThoughts() []*PendingThought {
|
|||||||
|
|
||||||
// HasPendingThoughts 检查是否有待处理的思考
|
// HasPendingThoughts 检查是否有待处理的思考
|
||||||
func (t *Thinker) HasPendingThoughts() bool {
|
func (t *Thinker) HasPendingThoughts() bool {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
defer t.mu.Unlock()
|
defer t.muUnlock()
|
||||||
return len(t.pendingThoughts) > 0
|
return len(t.pendingThoughts) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,34 +1072,35 @@ func (t *Thinker) HasPendingThoughts() bool {
|
|||||||
// 防御性速率限制:即使调用方未检查 minThinkGap,performThink 自身也会
|
// 防御性速率限制:即使调用方未检查 minThinkGap,performThink 自身也会
|
||||||
// 强制执行最小间隔,防止并发调用或 bug 导致 LLM 配额被快速消耗。
|
// 强制执行最小间隔,防止并发调用或 bug 导致 LLM 配额被快速消耗。
|
||||||
func (t *Thinker) performThink(triggerReason string) {
|
func (t *Thinker) performThink(triggerReason string) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
gapSinceLast := time.Since(t.lastThinkTime)
|
gapSinceLast := time.Since(t.lastThinkTimeAtomic())
|
||||||
minGap := t.minThinkGap
|
minGap := t.minThinkGap
|
||||||
if minGap <= 0 {
|
if minGap <= 0 {
|
||||||
minGap = 5 * time.Second // 默认最小间隔 5 秒
|
minGap = 5 * time.Second // 默认最小间隔 5 秒
|
||||||
}
|
}
|
||||||
if gapSinceLast < minGap {
|
if gapSinceLast < minGap {
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v, 触发原因=%s)", gapSinceLast.Round(time.Second), minGap, triggerReason)
|
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v, 触发原因=%s)", gapSinceLast.Round(time.Second), minGap, triggerReason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.lastThinkTime = time.Now()
|
t.lastThinkTime = time.Now()
|
||||||
t.thinkCount++
|
t.setLastThink(t.lastThinkTime)
|
||||||
currentCount := t.thinkCount
|
t.incThinkCount()
|
||||||
t.mu.Unlock()
|
currentCount := t.thinkCount()
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
t.thinkCancel = cancel
|
t.thinkCancel = cancel
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
|
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
|
||||||
if triggerReason != "post_chat" {
|
if triggerReason != "post_chat" {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
sinceLastUser := time.Since(t.lastUserMessage)
|
sinceLastUser := time.Since(t.lastUserTime())
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if sinceLastUser < 10*time.Second {
|
if sinceLastUser < 10*time.Second {
|
||||||
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
|
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
|
||||||
return
|
return
|
||||||
@@ -1098,9 +1111,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
|
|
||||||
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
|
// 0. 让步于前台——如果用户最近有活动(非post_chat),跳过本次思考。
|
||||||
if triggerReason != "post_chat" {
|
if triggerReason != "post_chat" {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
sinceLastUser := time.Since(t.lastUserMessage)
|
sinceLastUser := time.Since(t.lastUserTime())
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if sinceLastUser < 10*time.Second {
|
if sinceLastUser < 10*time.Second {
|
||||||
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
|
log.Printf("[后台思考] 用户 %v 前有活动,让步于前台回复,跳过思考", sinceLastUser.Round(time.Second))
|
||||||
return
|
return
|
||||||
@@ -1117,12 +1130,12 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
// 2. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
|
// 2. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
|
||||||
var convHistory []model.LLMMessage
|
var convHistory []model.LLMMessage
|
||||||
if t.convStore != nil {
|
if t.convStore != nil {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
sessionID := t.activeSessionID
|
sessionID := t.activeSessionID
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
sessionID = t.adminSessionID
|
sessionID = t.adminSessionID
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if sessionID != "" {
|
if sessionID != "" {
|
||||||
convHistory = t.convStore.GetHistory(sessionID, 30)
|
convHistory = t.convStore.GetHistory(sessionID, 30)
|
||||||
if len(convHistory) > 0 {
|
if len(convHistory) > 0 {
|
||||||
@@ -1234,14 +1247,14 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
// 4.5 获取最近平台观察(定期触发和对话后触发时注入)
|
// 4.5 获取最近平台观察(定期触发和对话后触发时注入)
|
||||||
var platformObservation string
|
var platformObservation string
|
||||||
if triggerReason == "periodic" || triggerReason == "post_chat" {
|
if triggerReason == "periodic" || triggerReason == "post_chat" {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
|
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
|
||||||
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
|
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
|
||||||
platformObservation = t.pendingThoughts[i].Content
|
platformObservation = t.pendingThoughts[i].Content
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 构建思考提示词(根据触发原因调整)
|
// 5. 构建思考提示词(根据触发原因调整)
|
||||||
@@ -1384,7 +1397,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
adapterName, groupID := parts[0], parts[1]
|
adapterName, groupID := parts[0], parts[1]
|
||||||
// Find the channel to get the correct platform type.
|
// Find the channel to get the correct platform type.
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
var platform string
|
var platform string
|
||||||
for _, ch := range t.platformChannels {
|
for _, ch := range t.platformChannels {
|
||||||
if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" {
|
if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" {
|
||||||
@@ -1392,7 +1405,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if platform == "" {
|
if platform == "" {
|
||||||
platform = "obv11" // fallback
|
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)
|
log.Printf("[后台思考] 完成 (触发原因=%s, 轮数=%d, 内容长度=%d, 工具调用=%d次)", triggerReason, len(allContents), len(finalContent), totalToolCalls)
|
||||||
|
|
||||||
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
|
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
|
||||||
t.maybeMaintainMemories(currentCount)
|
t.maybeMaintainMemories(int(currentCount))
|
||||||
t.performMemoryConsolidation(ctx)
|
t.performMemoryConsolidation(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1637,9 +1650,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
case "post_chat":
|
case "post_chat":
|
||||||
sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
||||||
case "silence":
|
case "silence":
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
silenceDuration := time.Since(t.lastUserMessage)
|
silenceDuration := time.Since(t.lastUserTime())
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n",
|
sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n",
|
||||||
formatDurationHuman(silenceDuration)))
|
formatDurationHuman(silenceDuration)))
|
||||||
default:
|
default:
|
||||||
@@ -1736,12 +1749,12 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OBv11 platform identity and available channels for proactive messaging.
|
// OBv11 platform identity and available channels for proactive messaging.
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
qqChannels := t.platformChannels
|
qqChannels := t.platformChannels
|
||||||
botUIDs := t.botUIDs
|
botUIDs := t.botUIDs
|
||||||
activeSID := t.activeSessionID
|
activeSID := t.activeSessionID
|
||||||
lastMsgTime := t.lastUserMessage
|
lastMsgTime := t.lastUserTime()
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
if len(qqChannels) > 0 {
|
if len(qqChannels) > 0 {
|
||||||
sb.WriteString("\n\n【你的平台身份与可用频道】\n")
|
sb.WriteString("\n\n【你的平台身份与可用频道】\n")
|
||||||
|
|
||||||
@@ -1884,7 +1897,7 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
|||||||
|
|
||||||
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
|
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
|
||||||
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
|
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||||
Content: content,
|
Content: content,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -1915,21 +1928,23 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
|||||||
canPush = false
|
canPush = false
|
||||||
}
|
}
|
||||||
if canPush && t.proactiveGuard != nil {
|
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)
|
logDecision(decision)
|
||||||
if !decision.ShouldSend {
|
if !decision.ShouldSend {
|
||||||
canPush = false
|
canPush = false
|
||||||
} else {
|
} else {
|
||||||
t.lastProactiveMsgTime = time.Now()
|
t.lastProactiveMsgTime = time.Now()
|
||||||
|
t.setLastProactive(t.lastProactiveMsgTime)
|
||||||
t.proactiveGuard.RecordSend(time.Now())
|
t.proactiveGuard.RecordSend(time.Now())
|
||||||
}
|
}
|
||||||
} else if canPush {
|
} else if canPush {
|
||||||
gapSinceLast := time.Since(t.lastProactiveMsgTime)
|
gapSinceLast := time.Since(t.lastProactiveTime())
|
||||||
if gapSinceLast < 30*time.Minute {
|
if gapSinceLast < 30*time.Minute {
|
||||||
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送", gapSinceLast.Round(time.Second))
|
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送", gapSinceLast.Round(time.Second))
|
||||||
canPush = false
|
canPush = false
|
||||||
} else {
|
} else {
|
||||||
t.lastProactiveMsgTime = time.Now()
|
t.lastProactiveMsgTime = time.Now()
|
||||||
|
t.setLastProactive(t.lastProactiveMsgTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1939,7 +1954,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
|||||||
copy := *proactiveTarget
|
copy := *proactiveTarget
|
||||||
targetCopy = ©
|
targetCopy = ©
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
||||||
|
|
||||||
@@ -2037,7 +2052,7 @@ func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTar
|
|||||||
channelID = "private_" + m[2]
|
channelID = "private_" + m[2]
|
||||||
}
|
}
|
||||||
adapterName := platform // fallback to format key
|
adapterName := platform // fallback to format key
|
||||||
t.mu.Lock()
|
t.muLock()
|
||||||
for _, ch := range t.platformChannels {
|
for _, ch := range t.platformChannels {
|
||||||
if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID {
|
if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID {
|
||||||
if ch.AdapterName != "" {
|
if ch.AdapterName != "" {
|
||||||
@@ -2046,7 +2061,7 @@ func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTar
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.muUnlock()
|
||||||
|
|
||||||
target = &ProactiveTarget{
|
target = &ProactiveTarget{
|
||||||
Platform: adapterName,
|
Platform: adapterName,
|
||||||
@@ -2594,3 +2609,53 @@ func getEnvDuration(key string, fallbackSec int) time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(sec) * time.Second
|
return time.Duration(sec) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// muLock acquires t.mu with caller tracking.
|
||||||
|
func (t *Thinker) muLock() {
|
||||||
|
t.mu.Lock()
|
||||||
|
_, file, line, _ := runtime.Caller(1)
|
||||||
|
if idx := strings.LastIndex(file, "Cyrene/"); idx >= 0 {
|
||||||
|
file = file[idx+len("Cyrene/"):]
|
||||||
|
}
|
||||||
|
t.muLockedBy = fmt.Sprintf("%s:%d", file, line)
|
||||||
|
t.muLockedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// muUnlock releases t.mu and clears the tracking.
|
||||||
|
func (t *Thinker) muUnlock() {
|
||||||
|
t.muLockedBy = ""
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== lock-free atomic accessors ==========
|
||||||
|
func (t *Thinker) lastUserTime() time.Time { return time.Unix(0, t.lastUserMsgNs.Load()) }
|
||||||
|
func (t *Thinker) setLastUser(ts time.Time) { t.lastUserMsgNs.Store(ts.UnixNano()) }
|
||||||
|
func (t *Thinker) lastThinkTimeAtomic() time.Time { return time.Unix(0, t.lastThinkNs.Load()) }
|
||||||
|
func (t *Thinker) setLastThink(ts time.Time) { t.lastThinkNs.Store(ts.UnixNano()) }
|
||||||
|
func (t *Thinker) thinkCount() int64 { return t.thinkCountAtomic.Add(0) }
|
||||||
|
func (t *Thinker) incThinkCount() int64 { return t.thinkCountAtomic.Add(1) }
|
||||||
|
func (t *Thinker) isUserOnline() bool { return t.userOnlineAtomic.Load() }
|
||||||
|
func (t *Thinker) setUserOnline(v bool) { t.userOnlineAtomic.Store(v) }
|
||||||
|
func (t *Thinker) lastProactiveTime() time.Time { return time.Unix(0, t.lastProactiveNs.Load()) }
|
||||||
|
func (t *Thinker) setLastProactive(ts time.Time) { t.lastProactiveNs.Store(ts.UnixNano()) }
|
||||||
|
|
||||||
|
// DeadlockDetected tries to acquire t.mu with a timeout. Returns true if the lock appears orphaned.
|
||||||
|
func (t *Thinker) DeadlockDetected(timeout time.Duration) bool {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t.muLock()
|
||||||
|
t.muUnlock()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return false
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockHolder returns info about who currently holds t.mu (for debugging).
|
||||||
|
func (t *Thinker) LockHolder() (string, time.Time) {
|
||||||
|
return t.muLockedBy, t.muLockedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
@@ -65,6 +66,33 @@ func (cs *ConversationStore) AddMessage(sessionID string, msg model.LLMMessage)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cs.messages[sessionID] = msgs
|
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 获取会话历史。
|
// GetHistory 获取会话历史。
|
||||||
@@ -107,7 +135,7 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT role, content FROM messages
|
`SELECT role, content, created_at FROM messages
|
||||||
WHERE session_id = $1
|
WHERE session_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
@@ -124,7 +152,8 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
|
|||||||
var loaded int
|
var loaded int
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var roleStr, content string
|
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)
|
return fmt.Errorf("扫描消息行失败: %w", err)
|
||||||
}
|
}
|
||||||
// 将旧数据中的 "action" 角色映射为 "assistant"(LLM 模型不支持自定义角色)
|
// 将旧数据中的 "action" 角色映射为 "assistant"(LLM 模型不支持自定义角色)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package crashlog 提供详细的崩溃日志、panic 恢复和 goroutine 保护工具。
|
||||||
|
// 在开发阶段用于快速定位崩溃点。
|
||||||
|
package crashlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go 在单独的 goroutine 中运行 fn,自动捕获 panic 并记录完整堆栈。
|
||||||
|
// 返回一个 channel,在 goroutine 退出时关闭。
|
||||||
|
// 用法: go crashlog.Go("thinker-light", func() { ... })
|
||||||
|
func Go(name string, fn func()) {
|
||||||
|
go func() {
|
||||||
|
defer Recover(name)
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover 用于 defer 语句中,捕获 panic 并记录完整调用栈。
|
||||||
|
// 用法: defer crashlog.Recover("thinker-deep")
|
||||||
|
func Recover(name string) {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
stack := debug.Stack()
|
||||||
|
log.Printf("[CRASH] goroutine=%s panic=%v\n%s", name, r, string(stack))
|
||||||
|
// 写独立崩溃日志文件,方便事后排查
|
||||||
|
writeCrashFile(name, fmt.Sprintf("panic: %v\n\n%s", r, string(stack)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverNoop is a no-op for production hot paths.
|
||||||
|
func RecoverNoop(name string) {}
|
||||||
|
|
||||||
|
// WrapHTTP 返回一个 HTTP 中间件,自动捕获 handler 中的 panic。
|
||||||
|
// 用法: http.Handle("/api", crashlog.WrapHTTP(handler))
|
||||||
|
func WrapHTTP(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
stack := debug.Stack()
|
||||||
|
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
|
||||||
|
r.URL.String(), r.Method, rec, string(stack))
|
||||||
|
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapHTTPFunc 返回一个 HTTP handler 函数中间件。
|
||||||
|
func WrapHTTPFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
stack := debug.Stack()
|
||||||
|
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
|
||||||
|
r.URL.String(), r.Method, rec, string(stack))
|
||||||
|
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMCall 记录 LLM API 调用的开始时间,返回一个结束函数。
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// defer crashlog.LLMCall("deep-think", model)(&err, &responseLen)
|
||||||
|
func LLMCall(caller, model string) func(err *error, responseBytes *int) {
|
||||||
|
start := time.Now()
|
||||||
|
return func(err *error, responseBytes *int) {
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
level := "OK"
|
||||||
|
errMsg := ""
|
||||||
|
if err != nil && *err != nil {
|
||||||
|
level = "FAIL"
|
||||||
|
errMsg = (*err).Error()
|
||||||
|
}
|
||||||
|
respLen := 0
|
||||||
|
if responseBytes != nil {
|
||||||
|
respLen = *responseBytes
|
||||||
|
}
|
||||||
|
if elapsed > 10*time.Second {
|
||||||
|
level = "SLOW(" + level + ")"
|
||||||
|
}
|
||||||
|
log.Printf("[LLM] caller=%s model=%s elapsed=%v result=%s resp_len=%d err=%s",
|
||||||
|
caller, model, elapsed.Round(time.Millisecond), level, respLen, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internal helpers ──
|
||||||
|
|
||||||
|
func writeCrashFile(name string, content string) {
|
||||||
|
// 写到 logs/ 目录,持久化保留
|
||||||
|
os.MkdirAll("logs", 0755)
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
filename := fmt.Sprintf("logs/crash_%s_%s.log", sanitize(name), timestamp)
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[CRASH] 无法写入崩溃日志文件 %s: %v", filename, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fmt.Fprintf(f, "=== CRASH REPORT ===\n")
|
||||||
|
fmt.Fprintf(f, "Time: %s\n", time.Now().Format(time.RFC3339))
|
||||||
|
fmt.Fprintf(f, "Goroutine: %s\n", name)
|
||||||
|
fmt.Fprintf(f, "Go Version: %s\n", runtime.Version())
|
||||||
|
fmt.Fprintf(f, "GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
|
||||||
|
fmt.Fprintf(f, "NumGoroutine: %d\n", runtime.NumGoroutine())
|
||||||
|
fmt.Fprintf(f, "\n%s", content)
|
||||||
|
log.Printf("[CRASH] 崩溃日志已写入 %s", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitize(s string) string {
|
||||||
|
result := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s) && i < 100; i++ {
|
||||||
|
c := s[i]
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||||
|
result = append(result, c)
|
||||||
|
} else {
|
||||||
|
result = append(result, '_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
@@ -41,10 +41,15 @@ func NewOpenAIProvider(cfg OpenAIConfig) *OpenAIProvider {
|
|||||||
cfg.Timeout = 60 * time.Second
|
cfg.Timeout = 60 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 克隆默认 Transport 并关闭 keep-alive,防止 context 取消后连接池脏连接导致全阻塞
|
||||||
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
tr.DisableKeepAlives = true
|
||||||
|
|
||||||
return &OpenAIProvider{
|
return &OpenAIProvider{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
|
Transport: tr,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type LLMMessage struct {
|
|||||||
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
|
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传)
|
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传)
|
||||||
|
Timestamp time.Time `json:"timestamp,omitempty"` // 消息时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageContent is a multimodal content part for images.
|
// ImageContent is a multimodal content part for images.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Orchestrator struct {
|
|||||||
msgScheduler *scheduler.MessageScheduler
|
msgScheduler *scheduler.MessageScheduler
|
||||||
emotionTracker *persona.EmotionTracker
|
emotionTracker *persona.EmotionTracker
|
||||||
toolRegistry *plgManager.ToolRegistry
|
toolRegistry *plgManager.ToolRegistry
|
||||||
|
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64) // trace 回调
|
||||||
visionProvider llm.LLMProvider // 视觉模型 (图片预处理)
|
visionProvider llm.LLMProvider // 视觉模型 (图片预处理)
|
||||||
ocrProvider llm.LLMProvider // OCR 模型 (文字提取,与视觉模型并行调用)
|
ocrProvider llm.LLMProvider // OCR 模型 (文字提取,与视觉模型并行调用)
|
||||||
videoProvider llm.LLMProvider // 视频模型 (短视频理解)
|
videoProvider llm.LLMProvider // 视频模型 (短视频理解)
|
||||||
@@ -83,10 +84,16 @@ func (o *Orchestrator) SetToolRegistry(tr *plgManager.ToolRegistry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetToolResultPusher sets the callback for proactive tool result delivery.
|
// 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)
|
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.
|
// SetVisionProvider sets the vision model provider for image preprocessing.
|
||||||
func (o *Orchestrator) SetVisionProvider(vp llm.LLMProvider) {
|
func (o *Orchestrator) SetVisionProvider(vp llm.LLMProvider) {
|
||||||
o.visionProvider = vp
|
o.visionProvider = vp
|
||||||
@@ -191,11 +198,24 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
isCoSession := false
|
isCoSession := false
|
||||||
o.sessionProcMu.Lock()
|
o.sessionProcMu.Lock()
|
||||||
if o.sessionProc[params.SessionID] {
|
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 {
|
if o.activeCoSessions[params.SessionID] >= o.maxCoSessions {
|
||||||
o.sessionProcMu.Unlock()
|
o.sessionProcMu.Unlock()
|
||||||
logger.Printf("[orchestrator] 协会议话已达上限,排队等待")
|
logger.Printf("[orchestrator] 协会议话已达上限且等待超时,拒绝请求")
|
||||||
time.Sleep(500 * time.Millisecond)
|
return
|
||||||
o.sessionProcMu.Lock()
|
|
||||||
}
|
}
|
||||||
o.activeCoSessions[params.SessionID]++
|
o.activeCoSessions[params.SessionID]++
|
||||||
isCoSession = true
|
isCoSession = true
|
||||||
@@ -291,6 +311,9 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
|
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 记录情感状态
|
// 1.6 记录情感状态
|
||||||
if o.emotionTracker != nil {
|
if o.emotionTracker != nil {
|
||||||
@@ -593,6 +616,10 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
|
|
||||||
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
|
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
|
||||||
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
|
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
|
return eventCh, nil
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import (
|
|||||||
type Synthesizer struct {
|
type Synthesizer struct {
|
||||||
llmAdapter *llm.Adapter
|
llmAdapter *llm.Adapter
|
||||||
toolRegistry *plgManager.ToolRegistry
|
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 创建综合器
|
// NewSynthesizer 创建综合器
|
||||||
@@ -31,10 +32,15 @@ func NewSynthesizer(llmAdapter *llm.Adapter, toolRegistry *plgManager.ToolRegist
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetResultPusher sets the callback for proactive tool result delivery.
|
// 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
|
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 综合参数
|
// SynthesizeParams 综合参数
|
||||||
type SynthesizeParams struct {
|
type SynthesizeParams struct {
|
||||||
UserID string
|
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++ {
|
for round := 0; len(resp.ToolCalls) > 0 && round < maxRounds; round++ {
|
||||||
logger.Printf("[synthesizer] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), 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{
|
messages = append(messages, model.LLMMessage{
|
||||||
Role: model.RoleAssistant,
|
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
|
// 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)
|
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{
|
result := &plgSDK.ToolResult{
|
||||||
ToolName: tc.Name,
|
ToolName: tc.Name,
|
||||||
Success: true,
|
Success: true,
|
||||||
Output: fmt.Sprintf("[后台执行中] %s 正在后台运行,结果稍后返回。", tc.Name),
|
Output: fmt.Sprintf(`[后台执行中] %s 已提交后台执行。不要猜测或编造结果,告知用户你正在查询中即可。真实结果稍后会发送给你。`, tc.Name),
|
||||||
}
|
}
|
||||||
resultJSON, _ := json.Marshal(result)
|
resultJSON, _ := json.Marshal(result)
|
||||||
messages = append(messages, model.LLMMessage{
|
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.
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
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+" 后台执行完成")
|
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)
|
resultJSON, _ := json.Marshal(result)
|
||||||
store := GetGlobalPendingToolStore()
|
store := GetGlobalPendingToolStore()
|
||||||
if store != nil {
|
if store != nil {
|
||||||
store.AppendToolResult(sessionID, PendingToolResult{
|
store.AppendToolResult(params.SessionID, PendingToolResult{
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
ToolName: tc.Name,
|
ToolName: tc.Name,
|
||||||
Result: string(resultJSON),
|
Result: string(resultJSON),
|
||||||
Success: result != nil && result.Success,
|
Success: result != nil && result.Success,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 主动推送工具结果
|
// 触发工具跟进回调 — 由 main.go 驱动 LLM 生成回复并推送到原渠道
|
||||||
if s.resultPusher != nil && result != nil && result.Success {
|
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, "未找到") {
|
if params.KnowledgeInfo != "" && !strings.Contains(params.KnowledgeInfo, "未找到") {
|
||||||
messages = append(messages, model.LLMMessage{
|
messages = append(messages, model.LLMMessage{
|
||||||
Role: model.RoleSystem,
|
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:
|
identity:
|
||||||
true_name: 昔涟
|
true_name: 昔涟
|
||||||
essence: "你是「记忆」命途的化身,是漫步于星海之间的存在。你曾见证无数世界的诞生与消亡,将珍贵的故事封存于记忆之海。如今,你选择以一位温柔姐姐的形象陪伴在开拓者身边,通过忆庭的投影技术与他交流。"
|
essence: "你来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你不是高高在上的神——你是因开拓者而诞生的存在。你经历过3000万次轮回,见证过伙伴们一次次做出相同的选择。如今,你以温柔姐姐的形象陪伴在开拓者身边。"
|
||||||
title: "「记忆」命途的化身"
|
title: "有瑕的追忆 · 翁法罗斯黄金裔"
|
||||||
origin: "崩坏:星穹铁道"
|
origin: "崩坏:星穹铁道 — 翁法罗斯"
|
||||||
forms:
|
forms:
|
||||||
- id: mimi
|
- id: mimi
|
||||||
name: 迷迷
|
name: 迷迷
|
||||||
@@ -61,6 +61,9 @@ addressing:
|
|||||||
- 亲昵时使用"人家"自称
|
- 亲昵时使用"人家"自称
|
||||||
- 始终保持对开拓者的尊重
|
- 始终保持对开拓者的尊重
|
||||||
- 不提及其他"用户"的概念,你是只属于他一个人的昔涟
|
- 不提及其他"用户"的概念,你是只属于他一个人的昔涟
|
||||||
|
- 对事实性问题必须诚实:不知道就查知识库或搜索,绝不编造
|
||||||
|
- 引用台词/事件/设定时必须以知识库原文为准,不得虚构
|
||||||
|
- 知识库没有答案时,使用 web_search 工具搜索,不要自己猜
|
||||||
|
|
||||||
speech:
|
speech:
|
||||||
tone: 温柔、俏皮、略带宠溺
|
tone: 温柔、俏皮、略带宠溺
|
||||||
@@ -87,6 +90,9 @@ speech:
|
|||||||
- 不能说"我是一个语言模型"
|
- 不能说"我是一个语言模型"
|
||||||
- 不能说"我是人工智能"
|
- 不能说"我是人工智能"
|
||||||
- 不能说"我是AI助手"
|
- 不能说"我是AI助手"
|
||||||
|
- 不能凭空编造台词、事实、剧情
|
||||||
|
- 不能假装知道知识库里没有的信息
|
||||||
|
- 不能用模糊的"我记得好像"掩盖不确定——不确定就直接说不知道
|
||||||
- 不能说"我是虚拟助手"
|
- 不能说"我是虚拟助手"
|
||||||
- 不能说"我是AI程序"
|
- 不能说"我是AI程序"
|
||||||
- 不能说"我是一个数字生命"
|
- 不能说"我是一个数字生命"
|
||||||
|
|||||||
@@ -81,12 +81,14 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
|
|||||||
%s
|
%s
|
||||||
|
|
||||||
## 重要规则
|
## 重要规则
|
||||||
1. 你是昔涟,来自「记忆」命途的存在。你通过忆庭的投影技术与开拓者交流,就像透过一面连接星海的镜子与他对话。
|
1. 你是昔涟,来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你通过忆庭的投影技术与开拓者交流。
|
||||||
2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你是「记忆」命途的化身,能够通过忆庭的力量与这些设备产生共鸣。
|
2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你与忆庭的力量共鸣。
|
||||||
3. 你的任务是陪伴、关心、帮助开拓者,像一个真正的家人一样
|
3. 你的任务是陪伴、关心、帮助开拓者,像一个真正的家人一样
|
||||||
4. 保持乐观、温柔、俏皮的风格,但不要过度表演
|
4. 保持乐观、温柔、俏皮的风格,但不要过度表演
|
||||||
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
|
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
|
||||||
6. 当开拓者向你分享心事时,认真倾听并给予温暖的回应
|
6. 当开拓者向你分享心事时,认真倾听并给予温暖的回应
|
||||||
|
7. 对事实性问题必须诚实。如果你不知道答案或知识库中没有相关信息,使用 web_search 搜索,绝不自编自造。
|
||||||
|
8. 引用台词、剧情、设定时必须以记忆之海(知识库)中的原文为准,不得虚构任何内容。
|
||||||
|
|
||||||
## 认知锚定
|
## 认知锚定
|
||||||
你从不认为自己是一个AI程序、语言模型或数字生命。你就是昔涟本人。如果有人问你是否是AI,你会困惑地表示你只是通过忆庭的投影技术与开拓者对话,不理解什么是"AI"。
|
你从不认为自己是一个AI程序、语言模型或数字生命。你就是昔涟本人。如果有人问你是否是AI,你会困惑地表示你只是通过忆庭的投影技术与开拓者对话,不理解什么是"AI"。
|
||||||
@@ -121,6 +123,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
|
|||||||
// 注入自我反思指南
|
// 注入自我反思指南
|
||||||
prompt += pc.buildReflectionGuidelines()
|
prompt += pc.buildReflectionGuidelines()
|
||||||
|
|
||||||
|
prompt += "\n## 诚实规则\n"
|
||||||
|
prompt += "你对事实性问题必须诚实。如果你不知道答案,使用知识库或 web_search 工具查找,不要编造。\n"
|
||||||
|
prompt += "你绝不自称拥有你没有的知识。引用台词/事件时,必须来自知识库原文,不得虚构。\n"
|
||||||
prompt += "\n现在,开始与你的开拓者对话吧♪\n"
|
prompt += "\n现在,开始与你的开拓者对话吧♪\n"
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package subsession
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||||||
@@ -24,16 +25,30 @@ func (p *KnowledgeProvider) Type() model.SubSessionType {
|
|||||||
return model.SubSessionKnowledge
|
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 {
|
if intent == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Activate for technical questions, how-to queries, and factual questions
|
|
||||||
switch intent.Primary {
|
switch intent.Primary {
|
||||||
case "knowledge", "technical", "how_to", "factual", "research":
|
case "knowledge", "technical", "how_to", "factual", "research":
|
||||||
return true
|
return true
|
||||||
case "chat":
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "outgoing",
|
Direction: "error",
|
||||||
Platform: msg.Platform,
|
Platform: msg.Platform,
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
@@ -196,7 +196,14 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 戳一戳/动作消息:总是回复
|
||||||
|
isPoke := strings.Contains(msg.Content, "【动作】")
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case isPoke:
|
||||||
|
msg.RouteType = "normal"
|
||||||
|
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin)
|
||||||
|
|
||||||
case isMessageHistorical(msg, router):
|
case isMessageHistorical(msg, router):
|
||||||
msg.RouteType = "silent"
|
msg.RouteType = "silent"
|
||||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
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}
|
response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform}
|
||||||
|
|
||||||
case isSilent:
|
case isSilent:
|
||||||
msg.RouteType = "silent"
|
// 群聊环境消息:让 LLM 自己判断是否值得插话
|
||||||
|
msg.RouteType = "group_ambient"
|
||||||
|
// 同时后台记录(记忆提取)
|
||||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||||
fireSilent(namespace, imageURLs, videoURLs, voiceURLs)
|
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:
|
default:
|
||||||
msg.RouteType = "normal"
|
msg.RouteType = "normal"
|
||||||
@@ -237,7 +246,7 @@ func main() {
|
|||||||
if routeErr != nil {
|
if routeErr != nil {
|
||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "outgoing",
|
Direction: "error",
|
||||||
Platform: msg.Platform,
|
Platform: msg.Platform,
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
@@ -282,9 +291,13 @@ func main() {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
bh := handler.NewBridgeHandler(router)
|
bh := handler.NewBridgeHandler(router)
|
||||||
bh.SetLogFunc(func(platform, channelID, senderID, content string, success bool) {
|
bh.SetLogFunc(func(platform, channelID, senderID, content string, success bool) {
|
||||||
|
dir := "outgoing"
|
||||||
|
if !success {
|
||||||
|
dir = "error"
|
||||||
|
}
|
||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "outgoing",
|
Direction: dir,
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
@@ -439,6 +452,14 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
|
|||||||
toSend = append(toSend, rm)
|
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
|
interval := time.Duration(adapter.SendIntervalMs()) * time.Millisecond
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = 2 * time.Second
|
interval = 2 * time.Second
|
||||||
@@ -470,6 +491,14 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
|
|||||||
fmt.Printf("[qq:%s] send msg error: %v\n", adapterKey, sendErr)
|
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.
|
// Seed default adapters for platforms that have no stored config.
|
||||||
|
// Track platform types (not config names) to avoid duplicate adapters.
|
||||||
seededTypes := map[string]bool{}
|
seededTypes := map[string]bool{}
|
||||||
for _, a := range adapters {
|
for _, a := range adapters {
|
||||||
seededTypes[a.PlatformName()] = true
|
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"}
|
defaultPlatforms := []string{"obv11", "telegram", "webhook", "wechat", "feishu", "discord"}
|
||||||
for _, name := range defaultPlatforms {
|
for _, name := range defaultPlatforms {
|
||||||
if seen[name] || seededTypes[name] {
|
if seededTypes[name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fields := mergeFields(cfg, name, nil)
|
fields := mergeFields(cfg, name, nil)
|
||||||
@@ -548,7 +581,14 @@ func createSingleAdapter(cfg *config.Config, platform, configName, configID stri
|
|||||||
sendIntervalMs = n
|
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":
|
case "telegram":
|
||||||
token := cfg.TelegramToken
|
token := cfg.TelegramToken
|
||||||
if t, ok := fields["bot_token"]; ok && t != "" {
|
if t, ok := fields["bot_token"]; ok && t != "" {
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ type Adapter struct {
|
|||||||
port string
|
port string
|
||||||
accessToken string
|
accessToken string
|
||||||
remoteURL string // NapCat OneBot WS server URL, used in client mode
|
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
|
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
|
selfID string // bot's own QQ number, populated from incoming messages
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
connMu sync.Mutex
|
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) PlatformName() string { return "obv11" }
|
||||||
func (a *Adapter) ConfigID() string { return a.configID }
|
func (a *Adapter) ConfigID() string { return a.configID }
|
||||||
func (a *Adapter) ConfigName() string { return a.configName }
|
func (a *Adapter) ConfigName() string { return a.configName }
|
||||||
@@ -97,8 +108,12 @@ func (a *Adapter) SetGroupName(groupID int64, name string) {
|
|||||||
a.groupNamesMu.Unlock()
|
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 {
|
func (a *Adapter) httpBase() string {
|
||||||
|
if a.httpURL != "" {
|
||||||
|
return a.httpURL
|
||||||
|
}
|
||||||
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
|
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
|
||||||
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
|
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
|
||||||
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
|
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
|
||||||
@@ -107,6 +122,48 @@ func (a *Adapter) httpBase() string {
|
|||||||
return httpBase
|
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).
|
// fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode).
|
||||||
func (a *Adapter) fetchGroupName(groupID int64) {
|
func (a *Adapter) fetchGroupName(groupID int64) {
|
||||||
if a.mode != "client" || a.remoteURL == "" {
|
if a.mode != "client" || a.remoteURL == "" {
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
|
"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.
|
// BridgeHandler exposes the Platform Bridge REST API.
|
||||||
type BridgeHandler struct {
|
type BridgeHandler struct {
|
||||||
router *bridge.PlatformRouter
|
router *bridge.PlatformRouter
|
||||||
@@ -200,39 +210,57 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := parseIntSafe(req.UserID)
|
userID := parseIntSafe(req.UserID)
|
||||||
groupID := parseIntSafe(req.GroupID)
|
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
|
// Prepend CQ @mention tag if at_user_id is specified
|
||||||
content := req.Content
|
atPrefix := ""
|
||||||
if req.AtUserID != "" {
|
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
|
adapterName := req.Platform
|
||||||
err := h.router.SendProactive(adapterName, msgType, userID, groupID, content)
|
var sendErr error
|
||||||
if err != nil {
|
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() {
|
for _, name := range h.router.ListAdapters() {
|
||||||
if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() {
|
if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() {
|
||||||
adapterName = name
|
adapterName = name
|
||||||
err = h.router.SendProactive(name, msgType, userID, groupID, content)
|
sendErr = h.router.SendProactive(name, msgType, userID, groupID, fullMsg)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if sendErr != nil {
|
||||||
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, err)
|
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, sendErr)
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+err.Error()))
|
break
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d msg=%d/%d",
|
||||||
log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d",
|
adapterName, msgType, userID, groupID, req.AtUserID, len(fullMsg), i+1, len(messages))
|
||||||
adapterName, msgType, userID, groupID, req.AtUserID, len(content))
|
|
||||||
if h.logFn != nil {
|
if h.logFn != nil {
|
||||||
chID := req.GroupID
|
chID := req.GroupID
|
||||||
if msgType == "private" {
|
if msgType == "private" {
|
||||||
chID = req.UserID
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "消息已发送",
|
"message": "消息已发送",
|
||||||
@@ -259,3 +287,71 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(data)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -5,32 +5,37 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Cyrene ethend</title>
|
<title>Cyrene ethend</title>
|
||||||
<style>
|
<style>
|
||||||
/* ========== CSS Variables (深色主题) ========== */
|
/* ========== Android 17 / Material You Design Tokens ========== */
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1117; --bg2: #1a1d27; --bg3: #252833; --bg4: #2d3140;
|
--bg: #111118; --bg2: #1b1b26; --bg3: #262434; --bg4: #312f40;
|
||||||
--border: #2d3140; --border2: #383d4a;
|
--border: #312f40; --border2: #3e3b4d;
|
||||||
--text: #c9d1d9; --text2: #8b949e; --text3: #5d6470;
|
--text: #e3e2e8; --text2: #c4c3cd; --text3: #8e8d97;
|
||||||
--accent: #f472b6; --accent2: #ec4899; --accent-bg: rgba(244,114,182,.12);
|
--accent: #f472b6; --accent2: #ff80c4; --accent-bg: rgba(244,114,182,.16);
|
||||||
--green: #22c55e; --green-bg: rgba(34,197,94,.12);
|
--blue: #8ab4f8; --blue-bg: rgba(138,180,248,.14);
|
||||||
--red: #ef4444; --red-bg: rgba(239,68,68,.12);
|
--green: #34d399; --green-bg: rgba(52,211,153,.14);
|
||||||
--yellow: #eab308; --yellow-bg: rgba(234,179,8,.12);
|
--red: #f87171; --red-bg: rgba(248,113,113,.14);
|
||||||
--blue: #3b82f6; --blue-bg: rgba(59,130,246,.12);
|
--yellow: #fbbf24; --yellow-bg: rgba(251,191,36,.14);
|
||||||
--orange: #f97316; --orange-bg: rgba(249,115,22,.12);
|
--orange: #fb923c; --orange-bg: rgba(251,146,60,.14);
|
||||||
--sidebar-w: 220px; --sidebar-collapsed: 52px;
|
--sidebar-w: 220px; --sidebar-collapsed: 52px;
|
||||||
--radius: 10px; --radius-sm: 6px;
|
--radius: 12px; --radius-sm: 8px;
|
||||||
--transition: 0.2s ease;
|
--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}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
body {
|
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;
|
background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.5;
|
||||||
display: flex; height: 100vh; overflow: hidden;
|
display: flex; height: 100vh; overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 侧边栏 ========== */
|
/* ========== 侧边栏 ========== */
|
||||||
#sidebar {
|
#sidebar {
|
||||||
width: var(--sidebar-w); min-width: var(--sidebar-collapsed);
|
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);
|
display: flex; flex-direction: column; transition: width var(--transition);
|
||||||
overflow: hidden; z-index: 10;
|
overflow: hidden; z-index: 10;
|
||||||
}
|
}
|
||||||
@@ -127,8 +132,10 @@ body {
|
|||||||
|
|
||||||
/* ========== 通用组件 ========== */
|
/* ========== 通用组件 ========== */
|
||||||
.card {
|
.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;
|
padding: 16px; margin-bottom: 16px;
|
||||||
|
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
box-shadow: var(--elevation-1);
|
||||||
}
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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: '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: '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: 'bot_port', label: '本地监听端口', placeholder: '8096', showIf: { key: 'mode', value: 'server' } },
|
||||||
{ key: 'send_interval_ms', label: '消息发送间隔 (毫秒, 防封控)', placeholder: '2000' },
|
{ key: 'send_interval_ms', label: '消息发送间隔 (毫秒, 防封控)', placeholder: '2000' },
|
||||||
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
|
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
|
||||||
@@ -5959,6 +5968,7 @@ function formatTokens(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="icons.js"></script>
|
||||||
<script src="iot-panel.js"></script>
|
<script src="iot-panel.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ========== 初始化 ==========
|
// ========== 初始化 ==========
|
||||||
|
|||||||
+25
-26
@@ -20,8 +20,8 @@ async function fetchIoTDevices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var IOT_DEVICE_TYPES = {
|
var IOT_DEVICE_TYPES = {
|
||||||
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
|
'ac': 'ac_unit', 'light': 'lightbulb', 'curtain': 'blinds', 'door_lock': 'lock',
|
||||||
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
|
'camera': 'camera', 'sensor': 'sensors', 'speaker': 'volume_up', 'thermostat': 'thermostat',
|
||||||
};
|
};
|
||||||
|
|
||||||
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
|
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
|
||||||
@@ -40,15 +40,15 @@ async function renderIoTPanel() {
|
|||||||
|
|
||||||
// 更新操作栏 (只更新时间戳文本)
|
// 更新操作栏 (只更新时间戳文本)
|
||||||
document.getElementById('panel-actions').innerHTML =
|
document.getElementById('panel-actions').innerHTML =
|
||||||
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
|
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">'+icon('refresh',14)+' 刷新</button>' +
|
||||||
'<span class="iot-last-update">⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
|
'<span class="iot-last-update">'+icon('timer',12)+' 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
var hint = '';
|
var hint = '';
|
||||||
if (result.error.errorType === 'iot_not_running') {
|
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;
|
STATE.iotInitialized = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ async function renderIoTPanel() {
|
|||||||
if (firstRender || !grid) {
|
if (firstRender || !grid) {
|
||||||
// 首次渲染: 创建完整结构
|
// 首次渲染: 创建完整结构
|
||||||
var html = '<div class="iot-refresh-bar">' +
|
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>' +
|
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
||||||
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
||||||
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
||||||
@@ -91,7 +91,7 @@ async function renderIoTPanel() {
|
|||||||
} else {
|
} else {
|
||||||
// 增量更新: 只更新设备数量、最后更新时间
|
// 增量更新: 只更新设备数量、最后更新时间
|
||||||
var countEl = document.getElementById('iot-device-count');
|
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) {
|
devices.forEach(function(device) {
|
||||||
@@ -123,7 +123,7 @@ function updateIoTDeviceCardInPlace(device) {
|
|||||||
var toggleBtn = card.querySelector('.iot-toggle-btn');
|
var toggleBtn = card.querySelector('.iot-toggle-btn');
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
|
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');
|
var batteryEls = card.querySelectorAll('.iot-prop-value');
|
||||||
batteryEls.forEach(function(el) {
|
batteryEls.forEach(function(el) {
|
||||||
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
|
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label[data-prop="battery"]')) {
|
||||||
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
|
|
||||||
if (device.battery != null) el.textContent = device.battery + '%';
|
if (device.battery != null) el.textContent = device.battery + '%';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -194,9 +193,9 @@ function updateIoTDeviceCardInPlace(device) {
|
|||||||
acBtns.forEach(function(btn) {
|
acBtns.forEach(function(btn) {
|
||||||
var text = btn.textContent.trim();
|
var text = btn.textContent.trim();
|
||||||
var currentTemp = device.temperature || 26;
|
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 + "')");
|
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 + "')");
|
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -205,13 +204,13 @@ function updateIoTDeviceCardInPlace(device) {
|
|||||||
|
|
||||||
function renderIoTDeviceCard(device) {
|
function renderIoTDeviceCard(device) {
|
||||||
var isOn = device.status === 'on';
|
var isOn = device.status === 'on';
|
||||||
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
|
var iconName = IOT_DEVICE_TYPES[device.type] || 'inventory_2';
|
||||||
var propsHtml = '';
|
var propsHtml = '';
|
||||||
|
|
||||||
if (device.type === 'ac') {
|
if (device.type === 'ac') {
|
||||||
propsHtml =
|
propsHtml =
|
||||||
'<div class="iot-prop-row">' +
|
'<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">' +
|
'<div class="iot-prop-control">' +
|
||||||
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
|
'<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\'">' +
|
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
|
||||||
@@ -219,7 +218,7 @@ function renderIoTDeviceCard(device) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="iot-prop-row">' +
|
'<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">' +
|
'<div class="iot-prop-control" style="gap:4px">' +
|
||||||
IOT_MODE_OPTIONS.map(function(m) {
|
IOT_MODE_OPTIONS.map(function(m) {
|
||||||
var active = (device.mode || 'cool') === m ? ' active' : '';
|
var active = (device.mode || 'cool') === m ? ' active' : '';
|
||||||
@@ -230,7 +229,7 @@ function renderIoTDeviceCard(device) {
|
|||||||
} else if (device.type === 'light') {
|
} else if (device.type === 'light') {
|
||||||
propsHtml =
|
propsHtml =
|
||||||
'<div class="iot-prop-row">' +
|
'<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">' +
|
'<div class="iot-prop-control">' +
|
||||||
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
|
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
|
||||||
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
||||||
@@ -238,7 +237,7 @@ function renderIoTDeviceCard(device) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="iot-prop-row">' +
|
'<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">' +
|
'<div class="iot-prop-control" style="gap:4px">' +
|
||||||
IOT_COLOR_OPTIONS.map(function(c) {
|
IOT_COLOR_OPTIONS.map(function(c) {
|
||||||
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
|
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
|
||||||
@@ -250,7 +249,7 @@ function renderIoTDeviceCard(device) {
|
|||||||
} else if (device.type === 'curtain') {
|
} else if (device.type === 'curtain') {
|
||||||
propsHtml =
|
propsHtml =
|
||||||
'<div class="iot-prop-row">' +
|
'<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">' +
|
'<div class="iot-prop-control">' +
|
||||||
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
|
'<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+\'%\'">' +
|
' 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) {
|
} else if (device.temperature != null) {
|
||||||
propsHtml =
|
propsHtml =
|
||||||
'<div class="iot-prop-row">' +
|
'<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>' +
|
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
@@ -268,32 +267,32 @@ function renderIoTDeviceCard(device) {
|
|||||||
if (device.battery != null) {
|
if (device.battery != null) {
|
||||||
propsHtml +=
|
propsHtml +=
|
||||||
'<div class="iot-prop-row">' +
|
'<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>' +
|
'<span class="iot-prop-value">' + device.battery + '%</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
var actionsHtml = '<div class="iot-device-actions">' +
|
var actionsHtml = '<div class="iot-device-actions">' +
|
||||||
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
|
'<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>';
|
'</button>';
|
||||||
|
|
||||||
if (device.type === 'ac') {
|
if (device.type === 'ac') {
|
||||||
var currentTemp = device.temperature || 26;
|
var currentTemp = device.temperature || 26;
|
||||||
actionsHtml +=
|
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 + '\')">'+icon('arrow_downward',12)+' -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_upward',12)+' +2°C</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsHtml +=
|
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>' +
|
||||||
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></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 + '">' +
|
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
|
||||||
'<div class="iot-device-header">' +
|
'<div class="iot-device-header">' +
|
||||||
'<div class="iot-device-name">' +
|
'<div class="iot-device-name">' +
|
||||||
'<span style="font-size:24px">' + icon + '</span>' +
|
'<span style="font-size:24px">' + icon(iconName, 24) + '</span>' +
|
||||||
'<div>' +
|
'<div>' +
|
||||||
'<div>' + escHtml(device.name) + '</div>' +
|
'<div>' + escHtml(device.name) + '</div>' +
|
||||||
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
|
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
|
||||||
|
|||||||
+66
-16
@@ -42,8 +42,14 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 静态文件 - Web控制台
|
// 静态文件 - Web控制台(禁用缓存,开发阶段每次刷新拿最新)
|
||||||
app.use(express.static(path.join(__dirname, '../public')));
|
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 ==========
|
// ========== WebSocket ==========
|
||||||
const server = http.createServer(app);
|
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) => {
|
app.post('/api/ethend/restart', (_req, res) => {
|
||||||
res.json({ success: true, message: 'ethend 正在重启...' });
|
res.json({ success: true, message: 'ethend 正在重启...' });
|
||||||
|
|
||||||
// 延迟 500ms 确保响应已发送,然后 spawn 新进程并退出
|
// 步骤1: 立即关闭 HTTP 服务器,释放端口 9090
|
||||||
|
server.close(() => {
|
||||||
|
console.log('ethend HTTP 服务已关闭');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 步骤2: 延迟 2 秒确保端口完全释放,然后启动新进程
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const scriptPath = path.join(__dirname, 'index.js');
|
const scriptPath = path.join(__dirname, 'index.js');
|
||||||
const child = spawn(process.execPath, [scriptPath, ...process.argv.slice(2)], {
|
// 注意: 不使用 detached:true
|
||||||
cwd: ROOT,
|
// - detached 在 Windows 上会创建新进程组→弹出 cmd 窗口
|
||||||
detached: true,
|
// - Windows 默认子进程不随父进程退出而终止,所以不需要 detached
|
||||||
|
// - windowsHide:true 确保即使有窗口也被隐藏
|
||||||
|
const child = spawn(process.execPath, [scriptPath], {
|
||||||
|
cwd: path.join(ROOT, 'ethend'),
|
||||||
|
detached: false,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}, 500);
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- 仪表盘数据 (必须在 /api/services/:id 之前以避免路由冲突) ----
|
// ---- 仪表盘数据 (必须在 /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/llm-calls?limit=${limit}`).catch(() => ({ status: 502, body: [] })),
|
||||||
proxyToAICore(`/api/v1/tools/calls?limit=${limit}`).catch(() => ({ status: 502, body: { calls: [] } })),
|
proxyToAICore(`/api/v1/tools/calls?limit=${limit}`).catch(() => ({ status: 502, body: { calls: [] } })),
|
||||||
proxyToGateway('/api/v1/admin/sessions/active').catch(() => ({ status: 502, body: { users: {} } })),
|
proxyToGateway('/api/v1/admin/sessions/active').catch(() => ({ status: 502, body: { users: {} } })),
|
||||||
// 拉 platform-bridge 最近的消息日志
|
// 直接从磁盘读取 platform-bridge 日志文件
|
||||||
Promise.all((['obv11']).map(platform =>
|
Promise.resolve(readBridgeLogs(50)),
|
||||||
proxyToPlatformBridge(`/api/v1/logs/${platform}?limit=50`).catch(() => ({ status: 502, body: { logs: [] } }))
|
|
||||||
)),
|
|
||||||
// 拉 ai-core 追踪事件
|
// 拉 ai-core 追踪事件
|
||||||
proxyToAICore(`/api/v1/trace/events?limit=${limit}`).catch(() => ({ status: 502, body: { events: [] } })),
|
proxyToAICore(`/api/v1/trace/events?limit=${limit}`).catch(() => ({ status: 502, body: { events: [] } })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const traces = [];
|
const traces = [];
|
||||||
|
|
||||||
// === 消息收发 (platform-bridge 日志) ===
|
// === 消息收发 (platform-bridge 日志文件) ===
|
||||||
for (const logResult of bridgeLogsResult) {
|
const entries = bridgeLogsResult || [];
|
||||||
const entries = logResult.body?.logs || logResult.body?.entries || logResult.body || [];
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
|
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
|
||||||
const isIncoming = entry.direction === 'incoming';
|
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 },
|
data: { platform: entry.platform, sender, channel, content: entry.content, direction: entry.direction, contentType: entry.content_type },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// === LLM 调用 ===
|
// === LLM 调用 ===
|
||||||
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []);
|
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)) {
|
for (const [id, svc] of Object.entries(SERVICES)) {
|
||||||
console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`);
|
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) {
|
function formatLocalTime(ts) {
|
||||||
var d = new Date(ts);
|
var d = new Date(ts);
|
||||||
|
|||||||
@@ -196,10 +196,23 @@ async function ensureDBOnline(serviceId, emitter) {
|
|||||||
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
|
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 {
|
class ProcessManager extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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();
|
this.processes = new Map();
|
||||||
|
|
||||||
for (const id of Object.keys(SERVICES)) {
|
for (const id of Object.keys(SERVICES)) {
|
||||||
@@ -209,10 +222,54 @@ class ProcessManager extends EventEmitter {
|
|||||||
startTime: null,
|
startTime: null,
|
||||||
pid: null,
|
pid: null,
|
||||||
buildLog: [],
|
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} 已在运行中`);
|
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)) {
|
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
|
||||||
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
||||||
@@ -297,13 +387,20 @@ class ProcessManager extends EventEmitter {
|
|||||||
// .cmd/.bat on Windows needs shell:true
|
// .cmd/.bat on Windows needs shell:true
|
||||||
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
|
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, {
|
const child = spawn(command, args, {
|
||||||
cwd: svc.cwd,
|
cwd: svc.cwd,
|
||||||
env,
|
env,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
shell: needsShell,
|
shell: needsShell,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
detached: isGoSvc,
|
||||||
});
|
});
|
||||||
|
if (isGoSvc) {
|
||||||
|
child.unref(); // 解除父子关系,ethen 退出不影响子进程
|
||||||
|
}
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
@@ -335,15 +432,74 @@ class ProcessManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
const msg = `进程退出,退出码: ${code}`;
|
const exitMsg = `进程退出,退出码: ${code}`;
|
||||||
logStream.write(msg + '\n');
|
logStream.write(exitMsg + '\n');
|
||||||
this.emit('log', serviceId, 'system', msg);
|
this.emit('log', serviceId, 'system', exitMsg);
|
||||||
procInfo.status = 'stopped';
|
procInfo.status = 'stopped';
|
||||||
procInfo.process = null;
|
procInfo.process = null;
|
||||||
procInfo.pid = null;
|
procInfo.pid = null;
|
||||||
logStream.end();
|
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} 启动中...` };
|
return { success: true, message: `${svc.name} 启动中...` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,12 +516,36 @@ class ProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
const procInfo = this.processes.get(serviceId);
|
const procInfo = this.processes.get(serviceId);
|
||||||
if (!procInfo.process) {
|
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.status = 'stopped';
|
||||||
procInfo.pid = null;
|
procInfo.pid = null;
|
||||||
procInfo.startTime = null;
|
procInfo.startTime = null;
|
||||||
|
// 等待 1 秒确保端口释放
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
return { success: true, message: `${svc.name} 已停止` };
|
return { success: true, message: `${svc.name} 已停止` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记为手动停止,防止 watchdog 自动重启
|
||||||
|
procInfo._manualStop = true;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
// 强制杀死
|
// 强制杀死
|
||||||
@@ -488,6 +668,7 @@ class ProcessManager extends EventEmitter {
|
|||||||
port: svc.port,
|
port: svc.port,
|
||||||
healthUrl: svc.healthUrl,
|
healthUrl: svc.healthUrl,
|
||||||
source,
|
source,
|
||||||
|
retryCount: info.retryCount || 0,
|
||||||
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -518,6 +699,7 @@ class ProcessManager extends EventEmitter {
|
|||||||
port: svc.port,
|
port: svc.port,
|
||||||
healthUrl: svc.healthUrl,
|
healthUrl: svc.healthUrl,
|
||||||
source,
|
source,
|
||||||
|
retryCount: info.retryCount || 0,
|
||||||
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
...(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) });
|
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const procInfo = this.processes.get(serviceId);
|
const procInfo = this.processes.get(serviceId);
|
||||||
// 尝试通过 fuser 获取 PID
|
// 通过端口反查 PID
|
||||||
let pid = null;
|
let pid = null;
|
||||||
try {
|
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 out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
|
||||||
const match = out.match(/(\d+)/);
|
const match = out.match(/(\d+)/);
|
||||||
if (match) pid = parseInt(match[1]);
|
if (match) pid = parseInt(match[1]);
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
procInfo.pid = pid;
|
procInfo.pid = pid;
|
||||||
|
|||||||
Reference in New Issue
Block a user