mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat: 路径配置化 + 裸二进制启动 + pluginmgr 内置插件
- 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 发现和交互模式
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
||||
homed
|
||||
waiter
|
||||
*.test
|
||||
build/
|
||||
|
||||
111
PLAN.md
111
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_<plugin>` | 插件独立配置 | 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` | `<dataDir>/plugins` | 插件安装目录 |
|
||||
| `core.memory.graph` | `<dataDir>/memory/graph.db` | 图数据库 |
|
||||
| `core.memory.text` | `<dataDir>/memory/text` | 文本记忆目录 |
|
||||
| `core.memory.documents` | `<dataDir>/memory/documents` | 文档记忆目录 |
|
||||
| `core.knowledge.path` | `<dataDir>/knowledge` | 知识库目录 |
|
||||
| `core.skills.path` | `<dataDir>/skills` | 技能目录 |
|
||||
| `core.log.path` | `<dataDir>/log` | 日志目录 |
|
||||
|
||||
所有路径配置项均有 `ConfigDef` 元信息 + `password` 类型保护敏感字段。
|
||||
|
||||
### 裸二进制启动
|
||||
|
||||
- `-data` 默认值从硬编码 `/var/lib/homeagent` 改为 `""`(自动检测)
|
||||
- 自动检测:读取 `/proc/self/exe` 确定二进制所在目录 → `filepath.Join(exeDir, "data")`
|
||||
- 首次运行自动创建完整目录结构
|
||||
- 后续通过调整 ConfigRegistry 的值自定义各存储路径
|
||||
|
||||
## 构建与验证
|
||||
|
||||
|
||||
@ -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: <data>/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())
|
||||
|
||||
@ -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 <path> switch to a different unix socket
|
||||
/remote <url> switch to remote HTTP mode
|
||||
/local switch back to local socket mode
|
||||
/prompt <text> 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())
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
551
internal/plugins/pluginmgr/plugin.go
Normal file
551
internal/plugins/pluginmgr/plugin.go
Normal file
@ -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)
|
||||
}
|
||||
@ -110,11 +110,11 @@ code{font-family:monospace;font-size:12px;color:#a5b4fc}
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
let state={status:{},kernel:null,settings:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null};
|
||||
let state={status:{},kernel:null,settings:{},meta:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null};
|
||||
async function api(p,o){let opts={headers:{'Content-Type':'application/json',...o?.headers},...o};let r=await fetch('/api/v1'+p,opts);if(opts.raw)return r;let ct=r.headers.get('content-type')||'';if(ct.includes('json'))return r.json();return r.text()}
|
||||
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));let el=document.getElementById('tab-'+n);if(el)el.classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="\'+n+\'"]')||document.querySelector(`nav a[onclick*="${n}"]`)?.classList.add('active');renderAll()}
|
||||
function toast(m,isError){let t=document.getElementById('toast');t.textContent=m;t.className='toast'+(isError?' error':'');t.style.display='block';setTimeout(()=>t.style.display='none',3000)}
|
||||
async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()}
|
||||
async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};state.meta=s.meta||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()}
|
||||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
function timeAgo(t){let s=Math.floor((Date.now()-new Date(t).getTime())/1000);if(s<60)return s+'秒前';let m=Math.floor(s/60);if(m<60)return m+'分钟前';return Math.floor(m/60)+'小时前'}
|
||||
|
||||
@ -142,9 +142,9 @@ async function createKnowledge(){let name=document.getElementById('know-name')?.
|
||||
|
||||
// === Settings ===
|
||||
function renderSettingsSidebar(){let el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){let a=document.createElement('a');a.textContent=p;if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderOneSettings()};el.appendChild(a)})}
|
||||
function renderOneSettings(){let prefix=state.selectedSection+'.';let filtered=Object.keys(state.settings||{}).filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div><label>值</label><input id="inp-'+k.replace(/\./g,'_')+'" value="'+escHtml(sv)+'" onchange="markDirty(\''+k+'\')"></div>'})}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||||
function renderOneSettings(){let prefix=state.selectedSection+'.';let filtered=Object.keys(state.settings||{}).filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);let m=state.meta?.[k];let label=m?.display_name||'值';let desc=m?.description||'';let typ=m?.type||'string';let ph=m?.placeholder||'';let opts=m?.options||[];let inpId='inp-'+k.replace(/\./g,'_');let inp='';if(typ==='bool'){let chk=sv==='true'?'checked':'';inp='<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="'+inpId+'" '+chk+' onchange="markDirty(\''+k+'\')" style="width:auto;margin:0"> '+label+'</label>'}else if(typ==='password'){inp='<label>'+label+'</label><input type="password" id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}else if(typ==='text'){inp='<label>'+label+'</label><textarea id="'+inpId+'" onchange="markDirty(\''+k+'\')">'+escHtml(sv)+'</textarea>'}else if(typ==='select'&&opts.length>0){let sel='<label>'+label+'</label><select id="'+inpId+'" onchange="markDirty(\''+k+'\')">';opts.forEach(o=>{sel+='<option value="'+escHtml(o)+'"'+(sv===o?' selected':'')+'>'+escHtml(o)+'</option>'});sel+='</select>';inp=sel}else if(typ==='int'){inp='<label>'+label+'</label><input type="number" id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}else{inp='<label>'+label+'</label><input id="'+inpId+'" value="'+escHtml(sv)+'" placeholder="'+escHtml(ph)+'" onchange="markDirty(\''+k+'\')">'}html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div>'+inp;if(desc){html+='<p style="color:#64748b;font-size:11px;margin-top:-6px">'+escHtml(desc)+'</p>'}html+='</div>'})}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||||
function markDirty(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
||||
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let raw=inp.value;let val;try{val=JSON.parse(raw)}catch(e){val=raw}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||||
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let val;let m=state.meta?.[k];if(m?.type==='bool'){val=inp.checked?'true':'false'}else if(m?.type==='select'){val=inp.value}else{let raw=inp.value;try{val=JSON.parse(raw)}catch(e){val=raw}}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||||
function renderConfigDisabled(){document.getElementById('tab-settings').innerHTML='<div class="card"><h2>设置</h2><p style="color:#94a3b8">设置面板已加载</p></div>';renderOneSettings()}
|
||||
|
||||
// === Kernel ===
|
||||
|
||||
@ -644,12 +644,35 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
prefix := r.URL.Query().Get("prefix")
|
||||
keys := h.cfgReg.List(prefix)
|
||||
values := make(map[string]interface{})
|
||||
for _, k := range keys {
|
||||
v, _ := h.cfgReg.Get(k)
|
||||
values[k] = v
|
||||
meta := make(map[string]*internalConfig.ConfigDef)
|
||||
|
||||
if strings.HasPrefix(prefix, "plugin.") {
|
||||
// 插件配置:从插件自身 config_<name> 表读取
|
||||
pluginName := prefix[7:]
|
||||
ps := h.cfgReg.PluginConfig(pluginName)
|
||||
keys, _ := ps.List("")
|
||||
for _, k := range keys {
|
||||
v, _ := ps.Get(k)
|
||||
fullKey := prefix + "." + k
|
||||
values[fullKey] = v
|
||||
if def := h.cfgReg.GetDef(fullKey); def != nil {
|
||||
meta[fullKey] = def
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 核心配置:从 core config 表读取
|
||||
keys := h.cfgReg.List(prefix)
|
||||
for _, k := range keys {
|
||||
v, _ := h.cfgReg.Get(k)
|
||||
values[k] = v
|
||||
}
|
||||
defs := h.cfgReg.ListDefs(prefix)
|
||||
for _, d := range defs {
|
||||
meta[d.Key] = d
|
||||
}
|
||||
}
|
||||
|
||||
plugins := []string{"core"}
|
||||
if h.pluginReg != nil {
|
||||
for _, p := range h.pluginReg.List() {
|
||||
@ -659,6 +682,7 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Strings(plugins)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"settings": values,
|
||||
"meta": meta,
|
||||
"plugins": plugins,
|
||||
})
|
||||
case http.MethodPut:
|
||||
@ -670,9 +694,20 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.cfgReg.Set(body.Key, body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
if strings.HasPrefix(body.Key, "plugin.") {
|
||||
parts := strings.SplitN(body.Key, ".", 3)
|
||||
if len(parts) >= 3 {
|
||||
ps := h.cfgReg.PluginConfig(parts[1])
|
||||
if err := ps.Set(parts[2], body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := h.cfgReg.Set(body.Key, body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
default:
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
)
|
||||
|
||||
type ConfigDef = internalConfig.ConfigDef
|
||||
|
||||
type SettingsAPI interface {
|
||||
// 插件自身配置表 config_<name>
|
||||
Get(key string) (interface{}, error)
|
||||
@ -20,6 +22,10 @@ type SettingsAPI interface {
|
||||
SetPlugin(plugin, key string, value interface{}) error
|
||||
ListPlugin(plugin, prefix string) ([]string, error)
|
||||
|
||||
// 配置定义元信息
|
||||
RegisterDef(def internalConfig.ConfigDef)
|
||||
Defs(prefix string) []*internalConfig.ConfigDef
|
||||
|
||||
// 全局
|
||||
Dump() map[string]interface{}
|
||||
Plugins() []string
|
||||
@ -76,6 +82,20 @@ func (s *settingsImpl) ListCore(prefix string) ([]string, error) {
|
||||
return s.reg.List(prefix), nil
|
||||
}
|
||||
|
||||
func (s *settingsImpl) RegisterDef(def internalConfig.ConfigDef) {
|
||||
if s.reg == nil {
|
||||
return
|
||||
}
|
||||
s.reg.PluginConfig(s.pluginName).RegisterDef(def)
|
||||
}
|
||||
|
||||
func (s *settingsImpl) Defs(prefix string) []*internalConfig.ConfigDef {
|
||||
if s.reg == nil {
|
||||
return nil
|
||||
}
|
||||
return s.reg.PluginConfig(s.pluginName).ListDefs(prefix)
|
||||
}
|
||||
|
||||
func (s *settingsImpl) Dump() map[string]interface{} {
|
||||
if s.reg == nil {
|
||||
return nil
|
||||
|
||||
@ -135,9 +135,14 @@ type InputProcessingConfig struct {
|
||||
Audio AudioProcessingConfig `json:"audio" yaml:"audio"`
|
||||
}
|
||||
|
||||
type PluginDirConfig struct {
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Daemon DaemonConfig `json:"daemon"`
|
||||
LLM LLMConfig `json:"llm"`
|
||||
Plugin PluginDirConfig `json:"plugin"`
|
||||
InputProcessing InputProcessingConfig `json:"input_processing"`
|
||||
Defaults AgentConfig `json:"defaults"`
|
||||
Agents []AgentConfig `json:"agents"`
|
||||
|
||||
Reference in New Issue
Block a user