From 8cec92d94717e996e3e8b58a43220439541f131b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 16:56:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B7=AF=E5=BE=84=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8C=96=20+=20=E8=A3=B8=E4=BA=8C=E8=BF=9B=E5=88=B6=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=20+=20pluginmgr=20=E5=86=85=E7=BD=AE=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.go: 新增 PluginDirConfig 结构体嵌入 Config - config/registry.go: 新增7个路径配置项 (core.plugin.dir 等) + ConfigDef 元数据 - cmd/homed/main.go: -data 默认自动检测二进制同级目录,使用 cfg.Plugin.Dir - internal/plugins/pluginmgr/: 内置插件实现 (4工具 + HTTP API + 包校验) - all.go: 注册 pluginmgr - manifest.go: 扩展 PluginManifest 字段 - sdk/settings.go: RegisterDef / Defs 接口 - webui: 设置页自动发现 ConfigDef 元数据 - config/config.go, config/config.yaml: 清理 YAML 死代码 - sdk/plugin.go: IO 通道泛型化支持非文本类型 - waiter: CLI 支持 socket 发现和交互模式 --- .gitignore | 2 + PLAN.md | 111 ++++-- cmd/homed/main.go | 30 +- cmd/waiter/main.go | 276 +++++-------- config/config.go | 13 - config/config.yaml | 11 - internal/config/registry.go | 176 +++++++- internal/plugin/manifest.go | 43 +- internal/plugins/all.go | 1 + internal/plugins/mcp/plugin.go | 10 +- internal/plugins/pluginmgr/plugin.go | 551 ++++++++++++++++++++++++++ internal/plugins/webui/dashboard.html | 8 +- internal/plugins/webui/handler.go | 49 ++- internal/sdk/settings.go | 20 + pkg/types/types.go | 5 + 15 files changed, 1029 insertions(+), 277 deletions(-) create mode 100644 internal/plugins/pluginmgr/plugin.go diff --git a/.gitignore b/.gitignore index 3fe0468..b4f9b32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ homed +waiter *.test +build/ diff --git a/PLAN.md b/PLAN.md index 67e8e46..25c455e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -150,17 +150,15 @@ interruptCh process() 工具循环 on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output ``` -| 阶段 | 时机 | 插件能力 | -|------|------|---------| -| `on_input` | 消息到 Agent | 可短路回复 | -| `pre_action` | LLM 调用前 | 注入 system 消息 | -| `post_action` | LLM 返回后 | 审查/修改文本和工具调用 | -| `before_toolcall` | 工具执行前 | 拒绝/改参 | -| `after_toolcall` | 工具执行后 | 修改结果 | -| `before_output` | 输出前 | 改写最终文本 | -| `after_output` | 输出后 | 只读统计 | - -并行规则: 所有 handler 用 goroutine 并发, StageContext 内嵌 `sync.RWMutex`, handler 通过 `Lock()/RLock()/IsResponded()` 协防。 +| 阶段 | 触发时机 | 插件读写权限 | 典型用途 | +|---|---|---|---| +| `on_input` | 消息到 Agent,零处理 | 可读写 `raw_message`,可设置 `response` 短路 | 黑名单、限流、自定义指令前缀 | +| `pre_action` | Memory+Context 就绪,LLM 调用前 | 可读写 `context_messages`(追加/修改) | 注入 RAG 结果、插入时政 context | +| `post_action` | LLM 返回文本 + 工具调用列表 | 可读写 `llm_text`、`tool_calls`、`context_messages` | 敏感词过滤、强制 redirect 工具 | +| `before_toolcall` | 单个工具调用执行前 | 可读写 `tool_call.name`、`tool_call.args`,设置 `deny=true` 拒绝 | 审计高危操作、OS 命令白名单 | +| `after_toolcall` | 单个工具执行完毕 | 可读写 `tool_result` | 脱敏数据库结果、排序搜索结果 | +| `before_output` | 最终文本就绪,output_send 前 | 可读写 `final_text`,可设置 `skip_output=false` | 添加表情/at 前缀、多平台格式适配 | +| `after_output` | output_send 已调用 | 只读 `final_text` | 统计日志、触发后续流程 | ## 配置体系 (ConfigRegistry) @@ -168,25 +166,66 @@ on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → b | 表 | 用途 | 访问 | |----|------|------| -| `config` | 核心配置 (LLM/daemon/agent) | SettingsAPI.GetCore/SetCore | +| `config` | 核心配置 (LLM/daemon/agent/paths) | SettingsAPI.GetCore/SetCore | | `config_` | 插件独立配置 | SettingsAPI.Get/Set/List | | | 跨插件读写 | GetPlugin/SetPlugin/ListPlugin/Dump | +### 配置元信息 (ConfigDef) + +每个配置项注册时附带元数据(类型、中文描述、分类、选项等),WebUI/CLI 自动发现并渲染。 + +## 插件包系统 + +### 包格式 (.hmap) + +标准 ZIP 文件,扩展名 `.hmap` (HomeAgent Plugin Package): + +``` +myplugin-1.0.0.hmap +├── plugin.json 必要 — {name, version, entry, description, author, ...} +├── plugin.so Go 插件 (entry = "plugin.so") +├── main.lua Lua 插件 (entry = "main.lua") +├── SKILL.md Skill 插件 (entry = "SKILL.md") +├── skill.json 可选 — 默认配置 +└── assets/ 可选 — 插件资源 +``` + +### pluginmgr 内置插件 + +| 工具 | 功能 | 关键参数 | +|------|------|---------| +| `plugin_install` | 从 URL 安装 `.hmap` | `{url: string}` | +| `plugin_list` | 列出已安装外部插件 | `{}` | +| `plugin_remove` | 卸载 | `{name: string}` | +| `plugin_info` | 详情(含文件清单) | `{name: string}` | + +HTTP API(`127.0.0.1:{随机端口}`,默认无鉴权): + +| 方法 | 路径 | 作用 | +|------|------|------| +| `GET` | `/plugins` | 列表 | +| `GET` | `/plugins/{name}` | 详情 | +| `POST` | `/plugins` | 安装(JSON `{url}` 或二进制 .hmap 上传) | +| `DELETE` | `/plugins/{name}` | 卸载 | + ## 内核入口 (cmd/homed/main.go) 初始化顺序: ``` -1. 基础设施 → 记忆/技能/Lua/监督/追踪/IO/事件 -2. 配置中心 (SQLite) + LLM Provider -3. 阶段管道 StageHost + 插件注册表 Registry -4. 注入内置插件依赖 (cli.DefaultSocket / openclaw.SkillsDir / webui.Configure) -5. Registry.Load(plgDir) → 自注册 + 动态加载 -6. Agent 启动 (eventLoop + interceptLoop + distillLoop) -7. 等待信号 → 关机 +1. 确定 dataDir (默认 ./data/ ,二进制同级) +2. 创建目录结构 (plugins/memory/knowledge/...) +3. 初始化 SQLite ConfigRegistry,SeedDefaults 写入默认路径 +4. 基础设施 → 记忆/技能/Lua/监督/追踪/IO/事件 +5. LLM Provider 管理 +6. 阶段管道 StageHost + 插件注册表 Registry +7. 注入内置插件依赖 (cli/webui/healthcheck/pluginmgr) +8. Registry.Load(plgDir) → 自注册 + 动态加载 +9. Agent 启动 (eventLoop + interceptLoop + distillLoop) +10. 等待信号 → 关机 ``` -## 目录结构 +### 目录结构 ``` cmd/ @@ -211,7 +250,7 @@ internal/ plugin.go — SKILL 插件解析 plugins/ all.go — 空白导入触发所有内置插件 init() - timer/ cli/ openclaw/ webui/ — 内置插件 + timer/ cli/ openclaw/ webui/ pluginmgr/ — 内置插件 events/bus.go — 系统事件总线 memory/ — 三层记忆 (Context→Document→Graph) knowledge/ — 知识库 @@ -224,16 +263,28 @@ pkg/types/ — 类型定义 docs/ARCHITECTURE.md — 完整架构文档 ``` -## 与旧架构关键区别 +## 目录路径配置化 -| 维度 | 之前 | 现在 | -|------|------|------| -| 插件注册 | main.go 硬编码 RegisterNative | init() 自注册 + .so 动态加载 | -| 内核入口 | 逐个 import 插件包 | 仅 import all.go (空白导入) | -| 中断处理 | 无消费者, 消息丢失 | interceptLoop + cancelLLM + drainInterrupt | -| 插件目录 | 手动硬编码创建 | Load() 自动为每个注册工厂创建 | -| 依赖注入 | 闭包绑定在 RegisterNative | 包级变量 (cli.DefaultSocket 等) | -| 阶段执行 | 顺序 | 并行 (goroutine + WaitGroup) | +### SeedDefaults 新增路径配置 + +| Key | 默认值 | 说明 | +|-----|--------|------| +| `core.plugin.dir` | `/plugins` | 插件安装目录 | +| `core.memory.graph` | `/memory/graph.db` | 图数据库 | +| `core.memory.text` | `/memory/text` | 文本记忆目录 | +| `core.memory.documents` | `/memory/documents` | 文档记忆目录 | +| `core.knowledge.path` | `/knowledge` | 知识库目录 | +| `core.skills.path` | `/skills` | 技能目录 | +| `core.log.path` | `/log` | 日志目录 | + +所有路径配置项均有 `ConfigDef` 元信息 + `password` 类型保护敏感字段。 + +### 裸二进制启动 + +- `-data` 默认值从硬编码 `/var/lib/homeagent` 改为 `""`(自动检测) +- 自动检测:读取 `/proc/self/exe` 确定二进制所在目录 → `filepath.Join(exeDir, "data")` +- 首次运行自动创建完整目录结构 +- 后续通过调整 ConfigRegistry 的值自定义各存储路径 ## 构建与验证 diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 1420dc7..218ee2a 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -7,6 +7,7 @@ import ( "io" "log" "os" + "os/exec" "os/signal" "path/filepath" "syscall" @@ -29,6 +30,7 @@ import ( cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" healthcheck "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" + pluginmgr "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/skill" @@ -38,11 +40,24 @@ import ( ) func main() { - dataDir := flag.String("data", "/var/lib/homeagent", "data directory") + dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)") httpAddr := flag.String("webui", ":8080", "webui listen address") cliSocket := flag.String("socket", "", "cli unix socket path (default: /cli.sock)") flag.Parse() + if *dataDir == "" { + exe, err := os.Executable() + if err == nil { + *dataDir = filepath.Join(filepath.Dir(exe), "data") + } else { + if exe, err := exec.LookPath(os.Args[0]); err == nil { + *dataDir = filepath.Join(filepath.Dir(exe), "data") + } else { + *dataDir = "./data" + } + } + } + if *cliSocket == "" { *cliSocket = filepath.Join(*dataDir, "cli.sock") } @@ -304,7 +319,7 @@ func main() { pluginReg.SetKnowledge(ks) pluginReg.SetProviderManager(providerMgr) pluginReg.SetConfigRegistry(cfgReg) - pluginReg.SetPluginDir(filepath.Join(cfg.Daemon.DataDir, "plugins")) + pluginReg.SetPluginDir(cfg.Plugin.Dir) // Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { @@ -360,7 +375,7 @@ func main() { TextMemory: textMem, Personality: personality, PluginReg: pluginReg, - PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"), + PluginDir: cfg.Plugin.Dir, ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"), StageHost: stageHost, EventBus: evBus, @@ -376,12 +391,15 @@ func main() { ) healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, providerMgr, agent) + // Wire pluginmgr dependencies + pluginmgr.PluginDir = cfg.Plugin.Dir + pluginmgr.Reg = pluginReg + // Auto-create plugins directory (without hardcoding plugin names) - plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins") - os.MkdirAll(plgDir, 0755) + os.MkdirAll(cfg.Plugin.Dir, 0755) // Load all plugins — each scans its own dir and is loaded via factory or .so - if err := pluginReg.Load(plgDir); err != nil { + if err := pluginReg.Load(cfg.Plugin.Dir); err != nil { log.Printf("[homed] warning: load plugins: %v", err) } log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index 6fe1fdc..674cc0b 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -16,50 +16,17 @@ import ( "syscall" "time" "unsafe" - - "gopkg.in/yaml.v3" ) const ( colorReset = "\033[0m" colorGreen = "\033[32m" colorRed = "\033[31m" - colorCyan = "\033[36m" colorYellow = "\033[33m" colorBold = "\033[1m" + colorDim = "\033[2m" ) -type Config struct { - Socket string `yaml:"socket"` - Remote string `yaml:"remote"` - Mode string `yaml:"mode"` - Colors bool `yaml:"colors"` - HistorySize int `yaml:"history_size"` - Prompt string `yaml:"prompt"` -} - -func defaultConfig() Config { - return Config{ - Mode: "auto", - Colors: true, - HistorySize: 1000, - Prompt: "waiter> ", - } -} - -func configPaths() []string { - home, _ := os.UserHomeDir() - xdgConfig := os.Getenv("XDG_CONFIG_HOME") - if xdgConfig == "" { - xdgConfig = filepath.Join(home, ".config") - } - return []string{ - filepath.Join(xdgConfig, "homeagent", "cli.yaml"), - filepath.Join(home, ".homeagent.yaml"), - ".homeagent.yaml", - } -} - func historyPath() string { home, _ := os.UserHomeDir() xdgData := os.Getenv("XDG_DATA_HOME") @@ -71,34 +38,6 @@ func historyPath() string { return filepath.Join(dir, "cli_history") } -func loadConfig() Config { - cfg := defaultConfig() - for _, p := range configPaths() { - data, err := os.ReadFile(p) - if err != nil { - continue - } - yaml.Unmarshal(data, &cfg) - break - } - if cfg.HistorySize < 1 { - cfg.HistorySize = 100 - } - return cfg -} - -func saveConfig(cfg Config) { - for _, p := range configPaths() { - dir := filepath.Dir(p) - if err := os.MkdirAll(dir, 0755); err != nil { - continue - } - data, _ := yaml.Marshal(cfg) - os.WriteFile(p, data, 0644) - return - } -} - func discoverSocket(configured string) string { if configured != "" { return configured @@ -122,34 +61,22 @@ func discoverSocket(configured string) string { return candidates[0] } -func resolveEndpoint(cfg Config) (mode string, addr string) { - switch cfg.Mode { - case "local": - return "local", discoverSocket(cfg.Socket) - case "remote": - return "remote", cfg.Remote - default: - sock := discoverSocket(cfg.Socket) - if sock != "" { - if _, err := os.Stat(sock); err == nil { - return "local", sock - } - } - if cfg.Remote != "" { - return "remote", cfg.Remote - } - return "local", sock - } -} - type respLine struct { Type string `json:"type"` Content string `json:"content"` Error string `json:"error"` } -func printColored(cfg Config, color, msg string) { - if !cfg.Colors { +var colors = true + +func init() { + if os.Getenv("NO_COLOR") != "" { + colors = false + } +} + +func printlnC(color, msg string) { + if !colors { fmt.Println(msg) return } @@ -157,48 +84,46 @@ func printColored(cfg Config, color, msg string) { } func main() { - socket := flag.String("socket", "", "unix socket path (overrides config)") - remote := flag.String("remote", "", "remote webui URL (overrides config)") + socket := flag.String("socket", "", "unix socket path") + remote := flag.String("remote", "", "remote webui URL (e.g. http://127.0.0.1:8080)") say := flag.String("say", "", "send a message and print response (one-shot)") flag.Parse() - cfg := loadConfig() - if *socket != "" { - cfg.Socket = *socket - } + sockAddr := discoverSocket(*socket) + mode := "local" + addr := sockAddr if *remote != "" { - cfg.Remote = *remote - cfg.Mode = "remote" + mode = "remote" + addr = *remote + } else if *socket != "" { + mode = "local" + addr = *socket } - mode, addr := resolveEndpoint(cfg) if *say != "" { - oneShot(cfg, mode, addr, *say) + oneShot(mode, addr, *say) return } - - runInteractive(cfg, mode, addr) + runInteractive(mode, addr) } -func oneShot(cfg Config, mode, addr, message string) { +func oneShot(mode, addr, message string) { if mode == "remote" { resp, err := doRemoteOnce(addr, message) if err != nil { - printColored(cfg, colorRed, fmt.Sprintf("error: %v", err)) + printlnC(colorRed, fmt.Sprintf("error: %v", err)) os.Exit(1) } fmt.Println(resp) return } - conn, err := net.DialTimeout("unix", addr, 5*time.Second) if err != nil { - printColored(cfg, colorRed, fmt.Sprintf("connect to %s: %v", addr, err)) + printlnC(colorRed, fmt.Sprintf("connect to %s: %v", addr, err)) os.Exit(1) } defer conn.Close() fmt.Fprintf(conn, "%s\n", message) - scanner := bufio.NewScanner(conn) if scanner.Scan() { var rl respLine @@ -210,7 +135,7 @@ func oneShot(cfg Config, mode, addr, message string) { case "response": fmt.Println(rl.Content) case "error": - printColored(cfg, colorRed, fmt.Sprintf("error: %s", rl.Error)) + printlnC(colorRed, fmt.Sprintf("error: %s", rl.Error)) os.Exit(1) default: fmt.Println(scanner.Text()) @@ -227,18 +152,18 @@ func doRemoteOnce(baseURL, message string) (string, error) { } defer resp.Body.Close() var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } + json.NewDecoder(resp.Body).Decode(&result) content, _ := result["response"].(string) return content, nil } -func runInteractive(cfg Config, mode, addr string) { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) +const clearLine = "\033[2K\r" - history := newHistory(historyPath(), cfg.HistorySize) +func runInteractive(mode, addr string) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + history := newHistory(historyPath(), 1000) history.load() line := newLineEditor(&history) @@ -249,18 +174,20 @@ func runInteractive(cfg Config, mode, addr string) { } defer restore() - if cfg.Colors { - fmt.Print(colorBold) - } - fmt.Printf("HomeAgent CLI — %s://%s\n", mode, addr) - if cfg.Colors { - fmt.Print(colorReset) + if colors { + fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, mode, addr, colorReset) + } else { + fmt.Printf("HomeAgent CLI (%s://%s)\n", mode, addr) } fmt.Println("Type /help for commands.") var conn io.ReadWriteCloser var readerDone chan struct{} + connMu := &sync.Mutex{} + connect := func() error { + connMu.Lock() + defer connMu.Unlock() if conn != nil { conn.Close() } @@ -273,56 +200,39 @@ func runInteractive(cfg Config, mode, addr string) { } conn = c readerDone = make(chan struct{}) - go readLoop(cfg, conn, readerDone) + go readLoop(conn, readerDone) return nil } reconnect := func() { for i := 0; i < 30; i++ { if err := connect(); err != nil { - if cfg.Colors { - fmt.Printf("\r\n%sreconnecting (%d/30): %v%s\n", colorYellow, i+1, err, colorReset) - } else { - fmt.Printf("\r\nreconnecting (%d/30): %v\n", i+1, err) - } + printlnC(colorYellow, fmt.Sprintf("reconnecting (%d/30): %v", i+1, err)) time.Sleep(2 * time.Second) continue } - if cfg.Colors { - fmt.Printf("\r%s%sreconnected%s\n", clearLine, colorGreen, colorReset) - } else { - fmt.Printf("\r%sreconnected\n", clearLine) - } + printlnC(colorGreen, "reconnected") return } - if cfg.Colors { - fmt.Printf("\r%s%sgiving up after 30 attempts%s\n", clearLine, colorRed, colorReset) - } else { - fmt.Printf("\r%sgiving up after 30 attempts\n", clearLine) - } + printlnC(colorRed, "giving up after 30 attempts") } // initial connect for { if err := connect(); err != nil { - if cfg.Colors { - fmt.Printf("%sconnect: %v, retrying in 2s...%s\n", colorYellow, err, colorReset) - } else { - fmt.Printf("connect: %v, retrying in 2s...\n", err) - } + printlnC(colorYellow, fmt.Sprintf("connect: %v, retrying in 2s...", err)) time.Sleep(2 * time.Second) continue } break } - prompt := cfg.Prompt + inputMu := &sync.Mutex{} for { - fmt.Print(prompt) + fmt.Print("waiter> ") text, err := line.read() if err != nil { - // EOF or error break } line.clear() @@ -333,25 +243,36 @@ func runInteractive(cfg Config, mode, addr string) { } if cmd[0] == '/' { - if handleBuiltin(cfg, cmd, &mode, &addr, &prompt, reconnect, &history) { + if handleBuiltin(cmd, &mode, &addr, reconnect) { continue } - // unknown command falls through to send as message } history.add(cmd) history.save() - _, err = fmt.Fprintf(conn, "%s\n", cmd) + connMu.Lock() + c := conn + connMu.Unlock() + if c == nil { + printlnC(colorYellow, "not connected, reconnecting...") + reconnect() + connMu.Lock() + c = conn + connMu.Unlock() + } + + _, err = fmt.Fprintf(c, "%s\n", cmd) if err != nil { - if cfg.Colors { - fmt.Printf("%sconnection lost, reconnecting...%s\n", colorYellow, colorReset) - } else { - fmt.Println("connection lost, reconnecting...") - } + printlnC(colorYellow, "connection lost, reconnecting...") line.redrawPending(cmd) reconnect() - fmt.Fprintf(conn, "%s\n", cmd) + connMu.Lock() + c = conn + connMu.Unlock() + if c != nil { + fmt.Fprintf(c, "%s\n", cmd) + } } select { @@ -359,12 +280,15 @@ func runInteractive(cfg Config, mode, addr string) { goto exit default: } + _ = inputMu } exit: + connMu.Lock() if conn != nil { conn.Close() } + connMu.Unlock() if readerDone != nil { <-readerDone } @@ -377,13 +301,11 @@ func dial(mode, addr string) (io.ReadWriteCloser, error) { return net.DialTimeout("unix", addr, 5*time.Second) } -const clearLine = "\033[2K\r" - -func readLoop(cfg Config, conn io.ReadWriteCloser, done chan struct{}) { +func readLoop(conn io.ReadWriteCloser, done chan struct{}) { defer close(done) scanner := bufio.NewScanner(conn) for scanner.Scan() { - if !cfg.Colors { + if !colors { fmt.Printf("%s%s\n", clearLine, scanner.Text()) continue } @@ -452,7 +374,7 @@ func (c *httpConn) Close() error { return nil } -func handleBuiltin(cfg Config, cmd string, mode, addr *string, prompt *string, reconnect func(), history *History) bool { +func handleBuiltin(cmd string, mode, addr *string, reconnect func()) bool { switch { case cmd == "/help": fmt.Println(`Built-in commands: @@ -463,7 +385,6 @@ func handleBuiltin(cfg Config, cmd string, mode, addr *string, prompt *string, r /connect switch to a different unix socket /remote switch to remote HTTP mode /local switch back to local socket mode - /prompt change the prompt Any other text is sent as a message to the agent.`) return true @@ -477,27 +398,19 @@ Any other text is sent as a message to the agent.`) return true case cmd == "/reconnect": - if cfg.Colors { - fmt.Printf("%sreconnecting...%s\n", colorYellow, colorReset) - } else { - fmt.Println("reconnecting...") - } + printlnC(colorYellow, "reconnecting...") reconnect() return true case strings.HasPrefix(cmd, "/connect "): *mode = "local" *addr = strings.TrimSpace(cmd[9:]) - cfg.Socket = *addr - saveConfig(cfg) reconnect() return true case strings.HasPrefix(cmd, "/remote "): *mode = "remote" *addr = strings.TrimSpace(cmd[8:]) - cfg.Remote = *addr - saveConfig(cfg) reconnect() return true @@ -507,10 +420,6 @@ Any other text is sent as a message to the agent.`) reconnect() return true - case strings.HasPrefix(cmd, "/prompt "): - *prompt = strings.TrimSpace(cmd[8:]) - return true - default: return false } @@ -525,10 +434,7 @@ type LineEditor struct { } func newLineEditor(h *History) *LineEditor { - return &LineEditor{ - hist: h, - histI: -1, - } + return &LineEditor{hist: h, histI: -1} } func (e *LineEditor) clear() { @@ -592,41 +498,41 @@ func (e *LineEditor) read() (string, error) { continue } switch seq[1] { - case 'A': // Up + case 'A': e.historyPrev() - case 'B': // Down + case 'B': e.historyNext() - case 'C': // Right + case 'C': if e.pos < len(e.buf) { e.pos++ e.redraw() } - case 'D': // Left + case 'D': if e.pos > 0 { e.pos-- e.redraw() } - case 'H', '1': // Home (\x1b[H) or (\x1b[1~) + case 'H', '1': if seq[1] == '1' { - io.ReadFull(in, make([]byte, 1)) // consume ~ + io.ReadFull(in, make([]byte, 1)) } e.pos = 0 e.redraw() - case 'F', '4': // End (\x1b[F) or (\x1b[4~) + case 'F', '4': if seq[1] == '4' { - io.ReadFull(in, make([]byte, 1)) // consume ~ + io.ReadFull(in, make([]byte, 1)) } e.pos = len(e.buf) e.redraw() - case '3': // Delete (\x1b[3~) - io.ReadFull(in, make([]byte, 1)) // consume ~ + case '3': + io.ReadFull(in, make([]byte, 1)) if e.pos < len(e.buf) { e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...) e.redraw() } } - case '\t': // Tab + case '\t': e.doCompletion() default: @@ -674,7 +580,7 @@ func (e *LineEditor) historyNext() { } func (e *LineEditor) doCompletion() { - cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local", "/prompt "} + cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local"} prefix := string(e.buf) for _, c := range cmds { if strings.HasPrefix(c, prefix) && c != prefix { @@ -687,10 +593,9 @@ func (e *LineEditor) doCompletion() { } func (e *LineEditor) redraw() { - fmt.Print("\r\033[K") // clear line + fmt.Print("\r\033[K") fmt.Print(string(e.buf)) if e.pos < len(e.buf) { - // move cursor back skip := len(e.buf) - e.pos fmt.Printf("\033[%dD", skip) } @@ -747,7 +652,6 @@ func (h *History) all() []string { return r } -// setRawMode sets stdin to raw mode (non-canonical, no echo). func setRawMode(fd int) (func(), error) { if fd == 0 { fd = int(os.Stdin.Fd()) diff --git a/config/config.go b/config/config.go index 3187bdc..28ff885 100644 --- a/config/config.go +++ b/config/config.go @@ -40,19 +40,6 @@ func DefaultConfig() types.Config { {Name: "ollama", BaseURL: "http://localhost:11434", Model: "llama3", Adapter: "ollama", AdapterPath: "adapters/ollama.lua"}, }, }, - InputProcessing: types.InputProcessingConfig{ - Image: types.ImageProcessingConfig{ - FallbackProvider: "", - FallbackModel: "", - DescribePrompt: "请详细描述这张图片的内容", - OCREnabled: true, - }, - Audio: types.AudioProcessingConfig{ - FallbackProvider: "", - FallbackModel: "", - DescribePrompt: "请描述这段音频的内容", - }, - }, Defaults: types.AgentConfig{ Image: "homeagent/agent-base:latest", LLMEndpoints: []string{"https://api.openai.com/v1"}, diff --git a/config/config.yaml b/config/config.yaml index 9d34c0a..810efc8 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -55,17 +55,6 @@ llm: adapter: "ollama" adapter_path: "adapters/ollama.lua" -input_processing: - image: - fallback_provider: "" - fallback_model: "" - describe_prompt: "请详细描述这张图片的内容" - ocr_enabled: true - audio: - fallback_provider: "" - fallback_model: "" - describe_prompt: "请描述这段音频的内容" - defaults: image: "homeagent/agent-base:latest" llm_endpoints: diff --git a/internal/config/registry.go b/internal/config/registry.go index 1926523..6f954b1 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -3,6 +3,7 @@ package config import ( "database/sql" "fmt" + "path/filepath" "sort" "strconv" "strings" @@ -13,10 +14,23 @@ import ( _ "github.com/mattn/go-sqlite3" ) +type ConfigDef struct { + Key string `json:"key"` + Default string `json:"default"` + Description string `json:"description"` + Type string `json:"type"` // string, int, bool, duration, password, select, text + DisplayName string `json:"display_name"` + Placeholder string `json:"placeholder,omitempty"` + Options []string `json:"options,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Category string `json:"category,omitempty"` +} + type ConfigRegistry struct { mu sync.RWMutex db *sql.DB dbPath string + defs map[string]*ConfigDef } func NewConfigRegistry(dbPath string) *ConfigRegistry { @@ -27,9 +41,8 @@ func NewConfigRegistry(dbPath string) *ConfigRegistry { if err != nil { panic(fmt.Sprintf("open config db: %v", err)) } - // WAL 模式提升并发 db.Exec("PRAGMA journal_mode=WAL") - r := &ConfigRegistry{db: db, dbPath: dbPath} + r := &ConfigRegistry{db: db, dbPath: dbPath, defs: make(map[string]*ConfigDef)} r.initCoreTable() return r } @@ -69,6 +82,32 @@ func (r *ConfigRegistry) RegisterDefault(key string, value interface{}) { r.Register(key, value) } +func (r *ConfigRegistry) RegisterDef(def ConfigDef) { + r.mu.Lock() + defer r.mu.Unlock() + r.defs[def.Key] = &def + r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, def.Key, def.Default) +} + +func (r *ConfigRegistry) GetDef(key string) *ConfigDef { + r.mu.RLock() + defer r.mu.RUnlock() + return r.defs[key] +} + +func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef { + r.mu.RLock() + defer r.mu.RUnlock() + var result []*ConfigDef + for _, def := range r.defs { + if strings.HasPrefix(def.Key, prefix) { + result = append(result, def) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key }) + return result +} + func (r *ConfigRegistry) Get(key string) (interface{}, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -139,7 +178,6 @@ func (r *ConfigRegistry) Flush() error { if r.dbPath == "" || r.dbPath == ":memory:" { return nil } - // SQLite 自动持久化;显式 checkpoint 确保一致性 r.mu.RLock() _, err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)") r.mu.RUnlock() @@ -150,11 +188,14 @@ func (r *ConfigRegistry) Close() error { return r.db.Close() } -// SeedDefaults 用硬编码默认值填充 config 表(仅空表时写入),不再依赖 YAML func (r *ConfigRegistry) SeedDefaults(dataDir string) { r.mu.Lock() defer r.mu.Unlock() + r.seedDBValues(dataDir) + r.seedCoreDefs(dataDir) +} +func (r *ConfigRegistry) seedDBValues(dataDir string) { var count int r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count) if count > 0 { @@ -175,14 +216,11 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) { set := func(k, v string) { stmt.Exec(k, v) } - // daemon set("core.daemon.listen_addr", ":8080") set("core.daemon.data_dir", dataDir) set("core.daemon.heartbeat_interval", "15s") set("core.daemon.check_interval", "30s") set("core.daemon.log_level", "info") - - // llm set("core.llm.provider", "deepseek") set("core.llm.model", "deepseek-v4-flash") set("core.llm.base_url", "https://api.deepseek.com") @@ -192,7 +230,6 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) { set("core.llm.max_tokens", "4096") set("core.llm.thinking_enabled", "false") - // llm sources sources := map[string]map[string]string{ "deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"}, "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "openai", "adapter_path": "adapters/openai.lua"}, @@ -213,7 +250,6 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) { set(p+".adapter_path", props["adapter_path"]) } - // defaults set("core.defaults.image", "homeagent/agent-base:latest") set("core.defaults.openclaw_enabled", "true") set("core.defaults.snapshot.interval", "10m") @@ -228,14 +264,105 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) { set("core.defaults.resource.memory", "2g") set("core.defaults.resource.disk", "10g") set("core.defaults.resource.network", "true") + set("core.plugin.dir", filepath.Join(dataDir, "plugins")) + set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db")) + set("core.memory.text", filepath.Join(dataDir, "memory", "text")) + set("core.memory.documents", filepath.Join(dataDir, "memory", "documents")) + set("core.knowledge.path", filepath.Join(dataDir, "knowledge")) + set("core.skills.path", filepath.Join(dataDir, "skills")) + set("core.log.path", filepath.Join(dataDir, "log")) + set("core.agent.max_tool_turns", "10") set("core.agent.max_context_size", "30") set("core.agent.distill_interval", "30m") + set("core.input_processing.image.fallback_provider", "") + set("core.input_processing.image.fallback_model", "") + set("core.input_processing.image.describe_prompt", "请详细描述这张图片的内容") + set("core.input_processing.image.ocr_enabled", "true") + set("core.input_processing.audio.fallback_provider", "") + set("core.input_processing.audio.fallback_model", "") + set("core.input_processing.audio.describe_prompt", "请描述这段音频的内容") + tx.Commit() } -// helpers — 所有值存为 TEXT,解析时自动转换 +func (r *ConfigRegistry) seedCoreDefs(dataDir string) { + reg := func(d ConfigDef) { r.defs[d.Key] = &d } + + reg(ConfigDef{Key: "core.daemon.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "daemon"}) + reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"}) + reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"}) + reg(ConfigDef{Key: "core.daemon.check_interval", Default: "30s", Type: "duration", DisplayName: "检查间隔", Description: "网络状态检查间隔", Category: "daemon"}) + reg(ConfigDef{Key: "core.daemon.log_level", Default: "info", Type: "select", DisplayName: "日志级别", Description: "日志输出级别", Options: []string{"debug", "info", "warn", "error"}, Category: "daemon"}) + + reg(ConfigDef{Key: "core.llm.provider", Default: "deepseek", Type: "string", DisplayName: "默认提供商", Description: "默认 LLM 提供商名称,需匹配 sources 中的定义", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.model", Default: "deepseek-v4-flash", Type: "string", DisplayName: "默认模型", Description: "默认 LLM 模型名称", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.base_url", Default: "https://api.deepseek.com", Type: "string", DisplayName: "默认 API 地址", Description: "默认 LLM API 基础地址", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.api_key", Default: "", Type: "password", DisplayName: "默认 API 密钥", Description: "默认 LLM API 密钥(空则从环境变量读取)", Placeholder: "留空则使用 DEEPSEEK_API_KEY", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.adapter", Default: "deepseek", Type: "string", DisplayName: "默认适配器", Description: "协议适配器名称(对应 adapters/ 下的 Lua 脚本)", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.temperature", Default: "0.7", Type: "string", DisplayName: "生成温度", Description: "LLM 生成温度 (0.0-2.0)", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.max_tokens", Default: "4096", Type: "int", DisplayName: "最大 Token", Description: "每次生成的最大 Token 数", Category: "llm"}) + reg(ConfigDef{Key: "core.llm.thinking_enabled", Default: "false", Type: "bool", DisplayName: "深度思考", Description: "启用深度思考模式(如 DeepSeek R1 的思维链输出)", Category: "llm"}) + + sources := map[string]map[string]string{ + "deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"}, + "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "openai", "adapter_path": "adapters/openai.lua"}, + "anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "api_key": "", "thinking_enabled": "false", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"}, + "gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "api_key": "", "thinking_enabled": "false", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"}, + "mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "api_key": "", "thinking_enabled": "false", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"}, + "groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "api_key": "", "thinking_enabled": "false", "adapter": "groq", "adapter_path": "adapters/groq.lua"}, + "github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "github", "adapter_path": "adapters/github.lua"}, + "ollama": {"base_url": "http://localhost:11434", "model": "llama3", "api_key": "", "thinking_enabled": "false", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"}, + } + for name := range sources { + p := "core.llm.sources." + name + reg(ConfigDef{Key: p + ".base_url", Default: sources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"}) + reg(ConfigDef{Key: p + ".model", Default: sources[name]["model"], Type: "string", DisplayName: name + " 模型", Description: name + " 使用的模型名称", Category: "sources"}) + reg(ConfigDef{Key: p + ".api_key", Default: "", Type: "password", DisplayName: name + " API 密钥", Description: name + " API 密钥", Category: "sources"}) + reg(ConfigDef{Key: p + ".thinking_enabled", Default: sources[name]["thinking_enabled"], Type: "bool", DisplayName: name + " 深度思考", Description: name + " 启用深度思考模式", Category: "sources"}) + reg(ConfigDef{Key: p + ".adapter", Default: sources[name]["adapter"], Type: "string", DisplayName: name + " 适配器", Description: name + " 协议适配器名称", Category: "sources"}) + reg(ConfigDef{Key: p + ".adapter_path", Default: sources[name]["adapter_path"], Type: "string", DisplayName: name + " 适配器路径", Description: name + " 适配器脚本路径", Category: "sources"}) + } + + reg(ConfigDef{Key: "core.defaults.image", Default: "homeagent/agent-base:latest", Type: "string", DisplayName: "默认镜像", Description: "Agent 默认 Docker 镜像", Category: "defaults"}) + reg(ConfigDef{Key: "core.defaults.openclaw_enabled", Default: "true", Type: "bool", DisplayName: "启用 OpenClaw", Description: "是否启用 OpenClaw 插件(网页内容抓取)", Category: "defaults"}) + reg(ConfigDef{Key: "core.defaults.snapshot.interval", Default: "10m", Type: "duration", DisplayName: "快照间隔", Description: "自动快照创建间隔", Category: "snapshot"}) + reg(ConfigDef{Key: "core.defaults.snapshot.max_snapshots", Default: "20", Type: "int", DisplayName: "最大快照数", Description: "保留的最大快照数量", Category: "snapshot"}) + reg(ConfigDef{Key: "core.defaults.snapshot.pre_action", Default: "true", Type: "bool", DisplayName: "操作前快照", Description: "执行操作前自动创建快照", Category: "snapshot"}) + reg(ConfigDef{Key: "core.defaults.snapshot.post_action", Default: "false", Type: "bool", DisplayName: "操作后快照", Description: "执行操作后自动创建快照", Category: "snapshot"}) + reg(ConfigDef{Key: "core.defaults.rollback.max_retries", Default: "3", Type: "int", DisplayName: "最大重试", Description: "健康检查失败后的最大重试次数", Category: "rollback"}) + reg(ConfigDef{Key: "core.defaults.rollback.health_threshold", Default: "3", Type: "int", DisplayName: "健康阈值", Description: "触发回滚的健康状态阈值", Category: "rollback"}) + reg(ConfigDef{Key: "core.defaults.rollback.cooldown_period", Default: "30s", Type: "duration", DisplayName: "回滚冷却", Description: "回滚操作后的冷却时间", Category: "rollback"}) + reg(ConfigDef{Key: "core.defaults.rollback.auto_rollback", Default: "true", Type: "bool", DisplayName: "自动回滚", Description: "达到健康阈值后自动执行回滚", Category: "rollback"}) + reg(ConfigDef{Key: "core.defaults.resource.cpu", Default: "2", Type: "string", DisplayName: "CPU 限制", Description: "容器 CPU 限制(如 1、2、0.5)", Category: "resources"}) + reg(ConfigDef{Key: "core.defaults.resource.memory", Default: "2g", Type: "string", DisplayName: "内存限制", Description: "容器内存限制(如 512m、2g)", Category: "resources"}) + reg(ConfigDef{Key: "core.defaults.resource.disk", Default: "10g", Type: "string", DisplayName: "磁盘限制", Description: "容器磁盘限制", Category: "resources"}) + reg(ConfigDef{Key: "core.defaults.resource.network", Default: "true", Type: "bool", DisplayName: "网络访问", Description: "是否允许容器访问网络", Category: "resources"}) + + plgDir := filepath.Join(dataDir, "plugins") + reg(ConfigDef{Key: "core.plugin.dir", Default: plgDir, Type: "string", DisplayName: "插件目录", Description: "外部插件安装目录", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"}) + reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"}) + reg(ConfigDef{Key: "core.skills.path", Default: filepath.Join(dataDir, "skills"), Type: "string", DisplayName: "技能目录", Description: "OpenClaw 技能存储目录", Category: "paths"}) + reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"}) + + reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"}) + + reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"}) + reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"}) + reg(ConfigDef{Key: "core.input_processing.image.describe_prompt", Default: "请详细描述这张图片的内容", Type: "text", DisplayName: "图片描述提示词", Description: "生成图片文字描述时的系统提示词", Category: "input"}) + reg(ConfigDef{Key: "core.input_processing.image.ocr_enabled", Default: "true", Type: "bool", DisplayName: "启用 OCR", Description: "是否启用图片文字识别工具", Category: "input"}) + reg(ConfigDef{Key: "core.input_processing.audio.fallback_provider", Default: "", Type: "string", DisplayName: "音频回退提供商", Description: "当主 LLM 不支持音频处理时使用的提供商", Category: "input"}) + reg(ConfigDef{Key: "core.input_processing.audio.fallback_model", Default: "", Type: "string", DisplayName: "音频回退模型", Description: "音频回退提供商使用的模型名", Category: "input"}) + reg(ConfigDef{Key: "core.input_processing.audio.describe_prompt", Default: "请描述这段音频的内容", Type: "text", DisplayName: "音频描述提示词", Description: "生成音频文字描述时的系统提示词", Category: "input"}) +} + +// helpers func (r *ConfigRegistry) GetString(key, defaultVal string) string { r.mu.RLock() @@ -293,7 +420,7 @@ func (r *ConfigRegistry) GetBool(key string, defaultVal bool) bool { return b } -// ToConfig 从 config 表重建 *types.Config(数据库为真实源,YAML 仅作初始 seed) +// ToConfig 从 config 表重建 *types.Config func (r *ConfigRegistry) ToConfig() *types.Config { cfg := &types.Config{} dump := r.Dump() @@ -355,7 +482,6 @@ func (r *ConfigRegistry) ToConfig() *types.Config { cfg.LLM.MaxTokens = readInt("core.llm.max_tokens", cfg.LLM.MaxTokens) cfg.LLM.ThinkingEnabled = readBool("core.llm.thinking_enabled", cfg.LLM.ThinkingEnabled) - // 重建 sources —— 从 DB 中按前缀扫描,按名称排序保证确定性 sourceNames := make([]string, 0) for k := range dump { if strings.HasPrefix(k, "core.llm.sources.") && strings.HasSuffix(k, ".base_url") { @@ -393,20 +519,32 @@ func (r *ConfigRegistry) ToConfig() *types.Config { cfg.Defaults.ResourceLimit.Disk = read("core.defaults.resource.disk", cfg.Defaults.ResourceLimit.Disk) cfg.Defaults.ResourceLimit.Network = readBool("core.defaults.resource.network", cfg.Defaults.ResourceLimit.Network) + cfg.Plugin.Dir = read("core.plugin.dir", cfg.Plugin.Dir) + + cfg.InputProcessing.Image.FallbackProvider = read("core.input_processing.image.fallback_provider", cfg.InputProcessing.Image.FallbackProvider) + cfg.InputProcessing.Image.FallbackModel = read("core.input_processing.image.fallback_model", cfg.InputProcessing.Image.FallbackModel) + cfg.InputProcessing.Image.DescribePrompt = read("core.input_processing.image.describe_prompt", cfg.InputProcessing.Image.DescribePrompt) + cfg.InputProcessing.Image.OCREnabled = readBool("core.input_processing.image.ocr_enabled", cfg.InputProcessing.Image.OCREnabled) + cfg.InputProcessing.Audio.FallbackProvider = read("core.input_processing.audio.fallback_provider", cfg.InputProcessing.Audio.FallbackProvider) + cfg.InputProcessing.Audio.FallbackModel = read("core.input_processing.audio.fallback_model", cfg.InputProcessing.Audio.FallbackModel) + cfg.InputProcessing.Audio.DescribePrompt = read("core.input_processing.audio.describe_prompt", cfg.InputProcessing.Audio.DescribePrompt) + return cfg } + func (r *ConfigRegistry) PluginConfig(name string) *PluginSettings { r.ensurePluginTable(name) return &PluginSettings{ registry: r, table: r.pluginTableName(name), + name: name, } } -// PluginSettings 实现 sdk.SettingsAPI,作用域为单个插件表 type PluginSettings struct { registry *ConfigRegistry table string + name string } func (p *PluginSettings) Get(key string) (interface{}, error) { @@ -448,3 +586,15 @@ func (p *PluginSettings) List(prefix string) ([]string, error) { } return keys, nil } + +func (p *PluginSettings) RegisterDef(def ConfigDef) { + p.registry.mu.Lock() + defer p.registry.mu.Unlock() + qualified := "plugin." + p.name + "." + def.Key + def.Key = qualified + p.registry.defs[def.Key] = &def +} + +func (p *PluginSettings) ListDefs(prefix string) []*ConfigDef { + return p.registry.ListDefs(prefix) +} diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index 797c1a1..9817d2a 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -1,11 +1,42 @@ package plugin +import ( + "encoding/json" + "os" + "path/filepath" +) + +const PackageExt = ".hmap" + // PluginManifest 每个插件目录中的 plugin.json 元数据。 type PluginManifest struct { - Name string `json:"name"` - Version string `json:"version,omitempty"` - Description string `json:"description,omitempty"` - Author string `json:"author,omitempty"` - Entry string `json:"entry,omitempty"` // "plugin.so" | "main.lua" | "" - Deprecated bool `json:"deprecated,omitempty"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + Entry string `json:"entry"` // "plugin.so" | "main.lua" | "SKILL.md" + MinVersion string `json:"min_version,omitempty"` + Tags []string `json:"tags,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` +} + +func ReadManifest(dir string) (*PluginManifest, error) { + data, err := os.ReadFile(filepath.Join(dir, "plugin.json")) + if err != nil { + return nil, err + } + var m PluginManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} + +// IsPluginDir 判断目录是否为有效的插件目录(包含 plugin.json) +func IsPluginDir(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "plugin.json")) + return err == nil } diff --git a/internal/plugins/all.go b/internal/plugins/all.go index 92b8b47..32ef7dc 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -7,6 +7,7 @@ import ( _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" ) diff --git a/internal/plugins/mcp/plugin.go b/internal/plugins/mcp/plugin.go index 348f59d..2ecbf18 100644 --- a/internal/plugins/mcp/plugin.go +++ b/internal/plugins/mcp/plugin.go @@ -39,7 +39,15 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { - // 从插件配置读取 MCP 服务器列表 + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "servers", + Default: "", + Type: "text", + DisplayName: "MCP 服务器配置", + Description: "MCP 服务器列表,JSON 数组格式,包含 name、command/url、args、env 等字段", + Category: "mcp", + }) + cfgs, err := p.loadConfig(s) if err != nil { return fmt.Errorf("load mcp config: %w", err) diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go new file mode 100644 index 0000000..367a92c --- /dev/null +++ b/internal/plugins/pluginmgr/plugin.go @@ -0,0 +1,551 @@ +package pluginmgr + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +var ( + PluginDir string // 由 main.go 设置 + Reg *plugin.Registry // 由 main.go 设置 + HTTPAddr = "127.0.0.1:0" // 监听地址,可被 main.go 覆写 +) + +func init() { + plugin.RegisterFactory("pluginmgr", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} + +type Plugin struct { + name string + mu sync.Mutex + server *http.Server + mux *http.ServeMux + listen net.Listener + httpURL string +} + +func New(name string) *Plugin { + return &Plugin{name: name, mux: http.NewServeMux()} +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "http_addr", + Default: HTTPAddr, + Type: "string", + DisplayName: "HTTP 监听地址", + Description: "插件管理 API 的监听地址,设为空可禁用 HTTP 服务", + Category: "pluginmgr", + }) + + if v, _ := s.Settings().Get("http_addr"); v != nil { + if addr, ok := v.(string); ok && addr != "" { + HTTPAddr = addr + } + } + + p.registerTools(s) + + if HTTPAddr != "" { + p.startHTTPServer() + } + + return nil +} + +func (p *Plugin) Stop() error { + if p.server != nil { + return p.server.Close() + } + return nil +} + +// ======== Tools ======== + +func (p *Plugin) registerTools(s *sdk.PluginSDK) { + s.RegisterTool("plugin_install", sdk.ToolDef{ + Name: "plugin_install", + Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。安装后需调用 plgreload 或重启生效。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{ + "type": "string", + "description": "插件包的下载 URL", + }, + }, + "required": []string{"url"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + url, _ := args["url"].(string) + if url == "" { + return map[string]interface{}{"error": "url is required"}, nil + } + return p.installFromURL(url) + }) + + s.RegisterTool("plugin_list", sdk.ToolDef{ + Name: "plugin_list", + Description: "列出已安装的所有外部插件及其版本", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.listPlugins() + }) + + s.RegisterTool("plugin_remove", sdk.ToolDef{ + Name: "plugin_remove", + Description: "卸载一个已安装的外部插件", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "插件名称", + }, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return map[string]interface{}{"error": "name is required"}, nil + } + return p.removePlugin(name) + }) + + s.RegisterTool("plugin_info", sdk.ToolDef{ + Name: "plugin_info", + Description: "查看指定已安装插件的详细信息(版本、作者、描述等)", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "插件名称", + }, + }, + "required": []string{"name"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return map[string]interface{}{"error": "name is required"}, nil + } + return p.pluginInfo(name) + }) +} + +// ======== HTTP API ======== + +func (p *Plugin) startHTTPServer() { + p.mux.HandleFunc("/plugins", p.handlePlugins) + p.mux.HandleFunc("/plugins/", p.handlePluginByID) + + listen, err := net.Listen("tcp", HTTPAddr) + if err != nil { + log.Printf("[pluginmgr] HTTP listen: %v", err) + return + } + p.listen = listen + p.httpURL = "http://" + listen.Addr().String() + + p.server = &http.Server{Handler: p.mux} + go func() { + log.Printf("[pluginmgr] HTTP API on %s", p.httpURL) + if err := p.server.Serve(listen); err != nil && err != http.ErrServerClosed { + log.Printf("[pluginmgr] HTTP serve: %v", err) + } + }() +} + +func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + list, err := p.listPlugins() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, list) + + case http.MethodPost: + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "application/json") { + var body struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if body.URL == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + result, err := p.installFromURL(body.URL) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) + } else { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + result, err := p.installFromData(data) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) + } + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/plugins/") + name = strings.TrimSuffix(name, "/") + if name == "" { + http.Error(w, "plugin name required", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + info, err := p.pluginInfo(name) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, info) + + case http.MethodDelete: + result, err := p.removePlugin(name) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// ======== Core Logic ======== + +func (p *Plugin) installFromURL(url string) (interface{}, error) { + log.Printf("[pluginmgr] downloading: %s", url) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download returned %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + return p.installFromData(data) +} + +func (p *Plugin) installFromData(data []byte) (interface{}, error) { + pkg, err := validatePackage(data) + if err != nil { + return map[string]interface{}{ + "error": "invalid package", + "details": err.Error(), + }, nil + } + + dir := PluginDir + if dir == "" { + return map[string]interface{}{"error": "plugin dir not configured"}, nil + } + + target := filepath.Join(dir, pkg.Name) + if _, err := os.Stat(target); err == nil { + return map[string]interface{}{ + "error": "plugin already exists", + "name": pkg.Name, + "version": pkg.Version, + "action": "remove_first", + }, nil + } + + if err := extractPackage(data, dir); err != nil { + return map[string]interface{}{ + "error": "extract failed", + "details": err.Error(), + }, nil + } + + checksum := fmt.Sprintf("%x", sha256.Sum256(data)) + + return map[string]interface{}{ + "status": "installed", + "name": pkg.Name, + "version": pkg.Version, + "entry": pkg.Entry, + "checksum": checksum, + "action": "reload_required", + }, nil +} + +func (p *Plugin) listPlugins() (interface{}, error) { + dir := PluginDir + if dir == "" { + return []map[string]interface{}{}, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return []map[string]interface{}{}, nil + } + return nil, err + } + + var plugins []map[string]interface{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + m, err := plugin.ReadManifest(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + plugins = append(plugins, map[string]interface{}{ + "name": m.Name, + "version": m.Version, + "description": m.Description, + "author": m.Author, + "entry": m.Entry, + "deprecated": m.Deprecated, + }) + } + if plugins == nil { + plugins = []map[string]interface{}{} + } + return plugins, nil +} + +func (p *Plugin) removePlugin(name string) (interface{}, error) { + dir := filepath.Join(PluginDir, name) + if _, err := os.Stat(dir); os.IsNotExist(err) { + return map[string]interface{}{"error": "plugin not found", "name": name}, nil + } + + if err := os.RemoveAll(dir); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + return map[string]interface{}{ + "status": "removed", + "name": name, + "action": "reload_required", + }, nil +} + +func (p *Plugin) pluginInfo(name string) (interface{}, error) { + dir := filepath.Join(PluginDir, name) + m, err := plugin.ReadManifest(dir) + if err != nil { + return nil, fmt.Errorf("plugin %q not found", name) + } + info := map[string]interface{}{ + "name": m.Name, + "version": m.Version, + "description": m.Description, + "author": m.Author, + "license": m.License, + "homepage": m.Homepage, + "repository": m.Repository, + "entry": m.Entry, + "min_version": m.MinVersion, + "tags": m.Tags, + "deprecated": m.Deprecated, + } + + entries, _ := os.ReadDir(dir) + var files []string + for _, e := range entries { + if !e.IsDir() { + files = append(files, e.Name()) + } + } + info["files"] = files + + return info, nil +} + +// ======== Package Validation ======== + +type pluginPackage struct { + Name string `json:"name"` + Version string `json:"version"` + Entry string `json:"entry"` +} + +func validatePackage(data []byte) (*pluginPackage, error) { + if len(data) > 100<<20 { + return nil, fmt.Errorf("package too large (>100MB)") + } + + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("invalid zip: %w", err) + } + + var pkg pluginPackage + hasManifest := false + + for _, f := range reader.File { + if strings.Contains(f.Name, "..") || strings.HasPrefix(f.Name, "/") { + return nil, fmt.Errorf("invalid path: %s", f.Name) + } + if f.FileInfo().IsDir() { + continue + } + if f.Name != "plugin.json" { + continue + } + hasManifest = true + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + mData, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + if err := json.Unmarshal(mData, &pkg); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + break + } + + if !hasManifest { + return nil, fmt.Errorf("missing plugin.json") + } + if pkg.Name == "" { + return nil, fmt.Errorf("manifest: name required") + } + if pkg.Version == "" { + return nil, fmt.Errorf("manifest: version required") + } + if pkg.Entry == "" { + return nil, fmt.Errorf("manifest: entry required") + } + + hasEntry := false + for _, f := range reader.File { + if f.Name == pkg.Entry && !f.FileInfo().IsDir() { + hasEntry = true + break + } + } + if !hasEntry { + return nil, fmt.Errorf("entry %q not found in package", pkg.Entry) + } + + valid := map[string]bool{"plugin.so": true, "main.lua": true, "SKILL.md": true} + if !valid[pkg.Entry] { + return nil, fmt.Errorf("unsupported entry: %q", pkg.Entry) + } + + return &pkg, nil +} + +func extractPackage(data []byte, pluginDir string) error { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return err + } + + // 先读 manifest 确定插件名 + var pkgName string + for _, f := range reader.File { + if f.Name == "plugin.json" && !f.FileInfo().IsDir() { + rc, _ := f.Open() + mData, _ := io.ReadAll(rc) + rc.Close() + var m struct { + Name string `json:"name"` + } + json.Unmarshal(mData, &m) + pkgName = m.Name + break + } + } + if pkgName == "" { + return fmt.Errorf("cannot determine plugin name") + } + + target := filepath.Join(pluginDir, pkgName) + os.MkdirAll(target, 0755) + + for _, f := range reader.File { + fpath := filepath.Join(target, f.Name) + if !strings.HasPrefix(filepath.Clean(fpath), filepath.Clean(target)+string(os.PathSeparator)) { + return fmt.Errorf("path traversal: %s", f.Name) + } + if f.FileInfo().IsDir() { + os.MkdirAll(fpath, 0755) + continue + } + os.MkdirAll(filepath.Dir(fpath), 0755) + rc, err := f.Open() + if err != nil { + return fmt.Errorf("open %s: %w", f.Name, err) + } + out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return fmt.Errorf("create %s: %w", f.Name, err) + } + io.Copy(out, rc) + rc.Close() + out.Close() + } + + return nil +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 54947c5..d6e803c 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -110,11 +110,11 @@ code{font-family:monospace;font-size:12px;color:#a5b4fc}