diff --git a/.gitignore b/.gitignore index 27f7e47..ab43411 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ homed waiter +*.exe +*.log *.test build/ *.so @@ -12,3 +14,4 @@ internal/meta/ internal/plugins/openclaw/manager/ internal/plugins/openclaw/pysimulator/ *.hmap +dev/ diff --git a/README.md b/README.md index d198468..432ed2f 100644 --- a/README.md +++ b/README.md @@ -157,12 +157,12 @@ internal/ ├── config/ SQLite 配置中心 ├── events/ 事件总线 └── lua/adapters/ 8 个 LLM 协议适配器脚本 -外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库的 `example/` 目录 +外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例 ``` ## 项目状态 -核心可用,插件系统和 SDK 已就绪。内置 10 个插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库。 +核心可用,插件系统和 SDK 已就绪。内置 10 个插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链。 ## 文档 diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 530203d..4264d7c 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -251,6 +251,9 @@ func main() { // ======================================================================== apiKey := cfg.LLM.APIKey + if apiKey == "" { + apiKey = os.Getenv("LLM_API_KEY") + } if apiKey == "" { apiKey = os.Getenv("DEEPSEEK_API_KEY") } @@ -337,9 +340,7 @@ func main() { // Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) // ======================================================================== - agent := agentCore.New(agentCore.AgentConfig{ - ID: "main", - SystemPrompt: `你是 HomeAgent,一个持续运行的个人管家。 + defaultPrompt := `你是 HomeAgent,一个持续运行的个人管家。 你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。 如需切换回复通道,使用 output_set_channel。 如需异步发送消息或通知,使用 output_send 指定通道和内容。 @@ -361,7 +362,15 @@ func main() { 当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。 -回复你的真实想法,用自然语言与用户交流。`, +回复你的真实想法,用自然语言与用户交流。` + sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt) + if sysPrompt == "" { + sysPrompt = defaultPrompt + } + + agent := agentCore.New(agentCore.AgentConfig{ + ID: "main", + SystemPrompt: sysPrompt, Provider: provider, ProviderManager: providerMgr, IO: iom, @@ -388,6 +397,7 @@ func main() { openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills") webui.Configure(*httpAddr, sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus, agent, + providerMgr, baseAPIKey, ) healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, providerMgr, agent) diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index dde5725..70676a9 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -11,7 +11,6 @@ import ( "strings" "syscall" "time" - "unsafe" ) const ( @@ -237,35 +236,4 @@ func printServerOutput(content string) { } } -func setRawMode(fd int) (func(), error) { - if fd == 0 { - fd = int(os.Stdin.Fd()) - } - if !isTerminal(fd) { - return func() {}, nil - } - var oldState syscall.Termios - if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&oldState)), 0, 0, 0); err != 0 { - return func() {}, fmt.Errorf("tcgets: %v", err) - } - newState := oldState - newState.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON - newState.Oflag &^= syscall.OPOST - newState.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN - newState.Cflag &^= syscall.CSIZE | syscall.PARENB - newState.Cflag |= syscall.CS8 - newState.Cc[syscall.VMIN] = 1 - newState.Cc[syscall.VTIME] = 0 - if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&newState)), 0, 0, 0); err != 0 { - return func() {}, fmt.Errorf("tcset: %v", err) - } - return func() { - syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&oldState)), 0, 0, 0) - }, nil -} -func isTerminal(fd int) bool { - var t syscall.Termios - _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0) - return err == 0 -} diff --git a/docs/PLUGIN_DEV.md b/docs/PLUGIN_DEV.md index 13b5db4..c417391 100644 --- a/docs/PLUGIN_DEV.md +++ b/docs/PLUGIN_DEV.md @@ -10,7 +10,6 @@ HomeAgent 的所有外部交互能力都来自插件。插件通过 `PluginSDK` ```bash git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git cd homeagent-sdk -hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin ``` 每个插件实现一个三方法接口: @@ -27,13 +26,351 @@ type Plugin interface { | 方式 | 适用场景 | 复杂度 | |------|---------|--------| -| **动态 .so 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 [SDK 仓库](https://gitcode.com/JianFeeeee/homeagent-sdk) 脚手架生成 | +| **动态 .so/.dll 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 | | **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 | -| **Lua 脚本插件** | 轻量快速原型 | 简单(预留功能) | +| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 | --- -## 一、快速开始:内置插件 +## 一、快速开始:使用 plugindev 工具链 + +`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。 + +### 安装 + +```bash +cd homeagent-sdk/tools/plugindev +go build -o plugindev.exe +# 将 plugindev.exe 加入 PATH 或直接使用 +``` + +### 创建 Go 插件 + +```bash +plugindev init myplugin +cd myplugin +# 编辑插件代码 +vim plugin.go +# 编译打包 +plugindev build +# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64) +``` + +### 创建 Lua 插件 + +```bash +plugindev init myluaplugin --lua +cd myluaplugin +# 编辑插件代码 +vim main.lua +# 本地测试 +lua main.lua +# 编译打包 +plugindev build +# 输出: dist/myluaplugin_lua.hmap +``` + +### 模板项目结构 + +**Go 插件**: + +``` +myplugin/ +├── plg.json — 插件元信息(名称、版本、入口、目标平台) +├── main.go — 入口点(非 Windows 或非 cgo 时编译) +├── plugin.go — 插件实现(Plugin 接口) +├── go.mod — Go 模块定义 +└── README.md — 说明文档 +``` + +**Lua 插件**: + +``` +myluaplugin/ +├── plg.json — 插件元信息(entry: "main.lua", targets: "lua") +├── main.lua — 插件实现(Plugin 接口的 Lua 版本) +├── sdk.lua — SDK 模拟层(支持 `lua main.lua` 独立测试) +└── README.md — 说明文档 +``` + +### 编译打包 + +`plugindev build` 会自动完成编译和打包: + +```bash +cd myplugin +plugindev build +``` + +执行过程: +1. 读取 `plg.json` 确定目标平台 +2. **Go 插件**:执行 `go build -buildmode=plugin`(Linux)或 `-buildmode=c-shared`(Windows) +3. **Lua 插件**:直接打包源码,无需编译 +4. 生成 `plugin.json` 清单文件 +5. 打包为 `.hmap` 分发包(zip 格式,内含 `plugin.json` + `plugin.so`/`plugin.dll`/`main.lua`) + +输出在 `dist/` 目录: +``` +dist/ +├── myplugin_linux_amd64.hmap # Go 插件 Linux 版 +├── myplugin_windows_amd64.hmap # Go 插件 Windows 版 +└── myplugin_lua.hmap # Lua 插件 +``` + +### 安装部署 + +通过 PluginMgr HTTP API 安装: + +```bash +# 内核 PluginMgr 监听 :9876 +curl -X POST http://127.0.0.1:9876/plugins \ + -F "file=@dist/myplugin_linux_amd64.hmap" +``` + +或通过 WebUI 插件管理页面上传安装。 + +--- + +## 二、Go 插件开发详解 + +### 插件接口 + +```go +package main + +import "gitcode.com/JianFeeeee/homeagent-sdk/sdk" + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + // 注册配置项、工具、阶段钩子等 + return nil +} + +func (p *Plugin) Stop() error { + // 清理资源 + return nil +} + +// NewPluginFactory 创建插件实例(由 main.go 或 Windows bridge 调用) +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} +``` + +### 入口点 + +`main.go` 提供了 `NewPlugin` 导出函数,它是内核加载插件时的入口: + +```go +//go:build !windows || !cgo + +package main + +import "gitcode.com/JianFeeeee/homeagent-sdk/sdk" + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return NewPluginFactory(name, config) +} +``` + +对于 Windows `-buildmode=c-shared`,`plugindev build` 自动生成 C ABI bridge 代码,无需手动处理。 + +### PluginSDK 核心 API + +#### 工具注册 — 让 LLM 可调用你的能力 + +```go +s.RegisterTool("weather_query", sdk.ToolDef{ + Name: "weather_query", + Description: "查询指定城市的天气", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city": map[string]interface{}{ + "type": "string", + "description": "城市名称,如 北京", + }, + }, + "required": []string{"city"}, + }, +}, func(args map[string]interface{}) (interface{}, error) { + city, _ := args["city"].(string) + return map[string]interface{}{ + "city": city, + "temp": 25, + "weather": "晴", + }, nil +}) +``` + +#### 阶段钩子 — 干预消息处理流 + +7 个阶段: + +| 阶段 | 时机 | 用途 | +|------|------|------| +| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路 | +| `pre_action` | 即将调用 LLM | 注入上下文 | +| `post_action` | LLM 返回结果 | 修改输出/工具列表 | +| `before_toolcall` | 工具调用前 | 审计、拒绝、改参 | +| `after_toolcall` | 工具执行后 | 脱敏、改写结果 | +| `before_output` | 输出前 | 格式适配 | +| `after_output` | 输出后 | 统计日志 | + +```go +s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error { + ctx.Lock() + ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ + "role": "system", + "content": "注入的上下文内容", + }) + ctx.Unlock() + return nil +}) +``` + +#### 配置管理 + +```go +// 注册配置项定义 +s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.myplugin.api_key", + Default: "", + Type: "string", + DisplayName: "API Key", + Description: "API 密钥", + Category: "myplugin", +}) + +// 读写配置 +val, err := s.Settings().Get("api_key") +s.Settings().Set("api_key", "new-value") + +// 读取核心配置 +s.Settings().GetCore("llm.model") + +// 读取其他插件配置 +s.Settings().GetPlugin("other_plugin", "some_key") +``` + +#### 输入投递 + +```go +// 排队投递(按序处理) +s.InjectInput(source, channel string, payload map[string]interface{}) + +// 中断投递(可打断当前 LLM 处理) +s.InjectInterrupt(source, channel string, payload map[string]interface{}) + +// 快捷方式 +s.InjectText(source, channel, text string) +s.InjectInterruptText(source, channel, text string) +``` + +#### 事件订阅 + +```go +unsub := s.Subscribe("tool_call", func(evt *events.Event) { + log.Printf("工具被调用: %v", evt.Payload) +}) +defer unsub() +``` + +#### 能力访问 + +```go +// 记忆 +s.Memory().Recall(query string) ([]MemItem, error) +s.Memory().Commit(triples []Triple) error + +// 知识 +s.Knowledge().Search(query string) ([]string, error) + +// LLM 源管理 +s.LLM().ListSources() []SourceInfo +s.LLM().SetSource(name string) error +``` + +--- + +## 三、Lua 插件开发详解 + +Lua 插件适合轻量级快速原型,无需 Go 编译环境,修改后直接重启内核即可生效。 + +### 插件结构 + +```lua +-- main.lua +local plugin = { + name = "myluaplugin" +} + +function plugin.start(sdk) + sdk.log("info", "myluaplugin starting...") + + sdk.register_tool("myluaplugin_hello", { + description = "A hello world tool", + parameters = { + type = "object", + properties = {} + } + }, function(args) + return { content = "Hello from myluaplugin plugin!" } + end) + + sdk.log("info", "myluaplugin started") +end + +function plugin.stop() + sdk.log("info", "myluaplugin stopped") +end + +return plugin +``` + +### SDK 模拟层 + +`sdk.lua` 提供纯 Lua 的 SDK 模拟实现,支持 `lua main.lua` 独立测试: + +```bash +lua main.lua +# 输出: +# [lua-plugin] info: myluaplugin starting... +# [lua-plugin] register_tool: myluaplugin_hello +# [lua-plugin] info: myluaplugin started +``` + +在内核中运行时,`sdk.*` 全局变量由 Go 层注入,所有 `-- !impl` 标记的函数会被替换为真实实现。 + +### Lua SDK API + +| 函数 | 说明 | +|------|------| +| `sdk.log(level, msg)` | 日志输出 | +| `sdk.register_tool(name, def, handler)` | 注册工具 | +| `sdk.register_stage(stage, handler)` | 注册阶段钩子 | +| `sdk.register_api(name)` | 注册 API | +| `sdk.get_setting(key)` | 读取配置 | +| `sdk.set_setting(key, value)` | 写入配置 | +| `sdk.inject_text(source, channel, text)` | 投递文本消息 | +| `sdk.inject_interrupt(source, channel, text)` | 中断投递 | +| `sdk.json.encode(val)` | JSON 编码 | +| `sdk.json.decode(str)` | JSON 解码 | +| `sdk.http.get(url)` | HTTP GET 请求(`-- !impl`) | +| `sdk.http.post(url, body, content_type)` | HTTP POST 请求(`-- !impl`) | + +--- + +## 四、内置插件 + +内置插件使用 `init()` 自注册方式,编译进内核,无需单独部署。 ### 目录结构 @@ -52,7 +389,6 @@ import ( sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) -// init() 将插件注册到全局工厂表,内核启动时自动发现并加载。 func init() { plugin.RegisterFactory("yourplugin", func(name string, config map[string]interface{}) (sdk.Plugin, error) { return New(name), nil @@ -93,358 +429,6 @@ import ( ) ``` -### 完整示例:定时器插件 - -`internal/plugins/timer/plugin.go` 是一个完整的内置插件示例: - -```go -package timer - -import ( - "fmt" - "log" - "sync" - "time" - - "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" -) - -func init() { - plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) { - return New(name), nil - }) -} - -type Plugin struct { - name string - mu sync.Mutex - wg sync.WaitGroup -} - -func New(name string) *Plugin { - return &Plugin{name: name} -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - // 注册一个工具给 LLM 调用 - return s.RegisterTool("timer_set", sdk.ToolDef{ - Name: "timer_set", - Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "duration": map[string]interface{}{ - "type": "string", - "description": "持续时间,例如 5s, 2m, 1h", - }, - "message": map[string]interface{}{ - "type": "string", - "description": "提醒内容", - }, - }, - "required": []string{"duration", "message"}, - }, - }, func(args map[string]interface{}) (interface{}, error) { - durStr, _ := args["duration"].(string) - message, _ := args["message"].(string) - dur, _ := time.ParseDuration(durStr) - - go func() { - time.Sleep(dur) - // 通过中断通道通知 agent - s.InjectInterruptText("timer", "timer", - fmt.Sprintf("timer: %s", message)) - }() - - return map[string]interface{}{ - "status": "timer_set", - "duration": durStr, - "message": message, - }, nil - }) -} - -func (p *Plugin) Stop() error { - p.wg.Wait() - return nil -} -``` - ---- - -## 二、插件开发详解 - -### PluginSDK 核心 API - -#### IO — 输入输出 - -```go -// 排队投递(按序处理) -sdk.InjectInput(source, channel string, payload map[string]interface{}) - -// 中断投递(可打断当前 LLM 处理) -sdk.InjectInterrupt(source, channel string, payload map[string]interface{}) - -// 快捷方式:text → Input -sdk.InjectText(source, channel, text string) -sdk.InjectInterruptText(source, channel, text string) - -// 同步请求-响应(CLI 插件使用) -sdk.InjectTextSync(source, channel, text string) *OutputEvent - -// 注册/管理输出通道(LLM 通过 output_send 选择发送到哪个通道) -sdk.RegisterChannel(name string, dev Device) error -sdk.UnregisterChannel(name string) -sdk.ListChannels() []ChannelInfo -``` - -#### 工具 — 让 LLM 可调用你的能力 - -```go -sdk.RegisterTool(name string, def ToolDef, handler ToolHandler) error -``` - -- `name`: LLM 通过此名称调用 -- `def`: JSON Schema 描述+参数 -- `handler`: 执行函数 - -```go -sdk.RegisterTool("weather_query", sdk.ToolDef{ - Name: "weather_query", - Description: "查询指定城市的天气", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{ - "type": "string", - "description": "城市名称,如 北京", - }, - }, - "required": []string{"city"}, - }, -}, func(args map[string]interface{}) (interface{}, error) { - city, _ := args["city"].(string) - return map[string]interface{}{ - "city": city, - "temp": 25, - "weather": "晴", - }, nil -}) -``` - -#### 阶段钩子 — 干预消息处理流 - -7 个阶段: - -| 阶段 | 时机 | 用途 | -|------|------|------| -| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路 | -| `pre_action` | 即将调用 LLM | 注入上下文 | -| `post_action` | LLM 返回结果 | 修改输出/工具列表 | -| `before_toolcall` | 工具调用前 | 审计、拒绝、改参 | -| `after_toolcall` | 工具执行后 | 脱敏、改写结果 | -| `before_output` | 输出前 | 格式适配 | -| `after_output` | 输出后 | 统计日志 | - -其中 `before_toolcall` / `after_toolcall` 阶段的 `StageContext` 会附带当前工具归属插件: -- `ctx.ToolCalls[i].Plugin` -- `ctx.ToolResults[i].Plugin` - -如果只想监听**当前插件自己的工具调用**,可使用: - -```go -s.RegisterStageOwnTools(sdk.StageBeforeToolcall, handler) -s.RegisterStageOwnTools(sdk.StageAfterToolcall, handler) -``` - -```go -sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { - if ctx.UserID == "blocked_user" { - resp := "你已被限制使用" - ctx.Response = &resp // 短路后续阶段 - return nil - } - return nil -}) -``` - -#### 事件 — 订阅/发布系统事件 - -```go -unsub := sdk.Subscribe(events.EventType("tool_call"), func(evt *events.Event) { - log.Printf("工具被调用: %v", evt.Payload) -}) -defer unsub() - -sdk.Publish(&events.Event{ - Type: "my_event", - Payload: map[string]interface{}{"key": "value"}, -}) -``` - -#### 能力访问 - -```go -// 记忆 -sdk.Memory().Recall(query string) ([]MemItem, error) -sdk.Memory().Commit(triples []Triple) error - -// 知识 -sdk.Knowledge().Search(query string) ([]string, error) - -// LLM 源管理 -sdk.LLM().ListSources() []SourceInfo -sdk.LLM().SetSource(name string) error - -// 配置(插件自身的 config_ 表) -sdk.Settings().Get(key string) (interface{}, error) -sdk.Settings().Set(key string, value interface{}) error -sdk.Settings().List(prefix string) ([]string, error) -``` - -### 读取插件配置 - -每插件独立 SQLite 表 `config_`: - -```go -val, err := s.Settings().Get("api_key") -if err != nil { - // 未配置 -} - -// 读取其他插件配置 -s.Settings().GetPlugin("other_plugin", "some_key") - -// 读取核心配置 -s.Settings().GetCore("llm.model") -``` - ---- - -## 三、插件需要外部依赖时的做法 - -有些插件在初始化时需要内核中的组件(数据库、LLM 管理器等)。采用**包级变量注入**模式: - -```go -package myplugin - -var DataDir string // 由 main.go 在 Load() 前设置 - -func init() { - plugin.RegisterFactory("myplugin", func(name string, config map[string]interface{}) (sdk.Plugin, error) { - return New(name, DataDir), nil - }) -} -``` - -在 `cmd/homed/main.go` 中: - -```go -myplugin.DataDir = filepath.Join(*dataDir, "myplugin_data") -pluginReg.Load(plgDir) // 之后调用 -``` - ---- - -## 四、动态 .so 插件 - -动态插件是独立于 HomeAgent 内核编译的 Go 插件,使用外部的 [Plugin SDK](https://gitcode.com/JianFeeeee/homeagent-sdk) -而非内核内部的 SDK 包。 - -完整的外部插件示例在 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库的 `example/` 目录下:`qq`、`files`、`memo`、`web`、`bili`、`editdoc`、`a2a`、`ocr`。 - -### 快速开始 - -使用 SDK 仓库的脚手架生成项目: - -```bash -git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git -cd homeagent-sdk -hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin -``` - -生成的代码: - -```go -package main - -import ( - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &myPlugin{name: name}, nil -} - -type myPlugin struct { - name string -} - -func (p *myPlugin) Name() string { return p.name } -func (p *myPlugin) Start(s *sdk.PluginSDK) error { - // 注册工具... - return nil -} -func (p *myPlugin) Stop() error { return nil } -``` - -### 编译 - -> ⚠️ **内核-插件编译绑定**:Go 的 `-buildmode=plugin` 要求 .so 插件与宿主内核(`homed`)的**所有重叠依赖包的 build ID 完全一致**。 -> 因此**每次重新编译内核后,所有外部 .so 插件必须同步重新编译**,否则 `plugin.Open` 将报错 -> `"plugin was built with a different version of package XXX"`。 -> -> 重新编译时需确保插件使用与内核相同的 SDK 版本和本地源码路径: -> ```bash -> SDK_VER="v0.0.0-20260708004841-e9bdcf9304b0" -> SDK_PATH="/path/to/homeagent-sdk-repo" # 与 go.work use 指向同一路径 -> go mod edit -require "gitcode.com/JianFeeeee/homeagent-sdk@${SDK_VER}" -> go mod edit -replace "gitcode.com/JianFeeeee/homeagent-sdk@${SDK_VER}=${SDK_PATH}" -> ``` -> 然后通过 `pluginmgr` 的 HTTP API (`:9876`) 或 `plugin_install` 工具重新安装。 - -```bash -cd -go build -buildmode=plugin -o plugins/myplugin/plugin.so plugins/myplugin/ -``` - -或使用项目中的 Makefile: - -```bash -cd plugins/myplugin && make -``` - -### 部署 - -将插件目录(含 `plugin.json` + `plugin.so`)放入内核配置的插件目录: - -``` -/plugins/myplugin/ - plugin.json — {"name": "myplugin", "version": "1.0", "description": "..."} - plugin.so — 编译产物 -``` - -内核扫描时会自动发现并加载。无需修改 `main.go` 或 `all.go`。 - -### 打包分发 - -使用 SDK 仓库的打包工具生成 `.hmap` 分发包: - -```bash -hack/plugin-dev/package.sh plugins/myplugin -# 输出: dist/myplugin-0.1.0.hmap -``` - -通过 WebUI 插件管理页面上传安装,或使用 `plugin_install` 工具。 - -### 完整示例 - -SDK 仓库的 `example/qq/` 目录提供了一个完整的 QQ 集成插件示例(对接 NapCat 框架), -涵盖消息收发、群管理、好友管理、文件操作、OCR 等功能,可作为开发参考。 - --- ## 五、最佳实践 @@ -455,10 +439,27 @@ SDK 仓库的 `example/qq/` 目录提供了一个完整的 QQ 集成插件示例 4. handler 返回 `error` 时 LLM 会收到并可能重试 5. 打断用 `InjectInterruptText`,普通投递用 `InjectText` 6. 配置用 `Settings().Get/Set`,不要硬编码 +7. Go 插件与内核编译绑定,每次重新编译内核后需同步重新编译 Go 插件 --- -## 六、现有插件参考 +## 六、示例插件参考 + +### SDK 仓库示例(`homeagent-sdk/example/`) + +| 示例 | 类型 | 特点 | +|------|------|------| +| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 | +| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 | +| [web](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/web) | Go | DuckDuckGo 搜索 + 网页抓取,SSRF 防护 | +| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具 | +| [bili](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/bili) | Go | B 站视频下载(you-get) | +| [editdoc](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/editdoc) | Go | Office 文档编辑与格式转换 | +| [a2a](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/a2a) | Go | Agent-to-Agent 协议 | +| [ocr](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ocr) | Go | 离线文字识别(Tesseract) | +| [sanitizer](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/sanitizer) | Go | 输出清洗过滤器 | +| [luaplugintest](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luaplugintest) | Lua | Lua 插件 Hello World | +| [testlua](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/testlua) | Lua | Lua 插件示例 | ### 内置插件 @@ -466,26 +467,10 @@ SDK 仓库的 `example/qq/` 目录提供了一个完整的 QQ 集成插件示例 |------|------|------| | Timer | `internal/plugins/timer/` | 最简单的完整示例,注册一个工具 + 中断反馈 | | CLI | `internal/plugins/cli/` | Unix socket 监听 + 同步请求响应 | -| OpenClaw | `internal/plugins/openclaw/` | 解析 SKILL.md 文件注册工具 | -| WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入(Configure 模式) | -| MCP | `internal/plugins/mcp/` | JSON-RPC over stdio/SSE,连接 MCP 服务器 | - -### 外部插件示例 - -| 插件 | 位置 | 特点 | -|------|------|------| -| QQ | `example/qq/` in [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) | NapCat 框架对接,17 个工具,RCON 转发/文档读取/视频下载/CQ码解析 | -| Files | `example/files/` in homeagent-sdk | 文件系统操作,4 种写入模式,沙箱隔离 | -| Web | `example/web/` in homeagent-sdk | DuckDuckGo 搜索 + 网页抓取,SSRF 防护 | -| Memo | `example/memo/` in homeagent-sdk | 备忘管理,PreAction 注入 + 定时打断双提醒 | -| Bili | `example/bili/` in homeagent-sdk | B 站视频下载(yt-dlp) | -| EditDoc | `example/editdoc/` in homeagent-sdk | Office 文档编辑与格式转换 | -| A2A | `example/a2a/` in homeagent-sdk | Agent-to-Agent 协议 | -| OCR | `example/ocr/` in homeagent-sdk | 离线文字识别(Tesseract) | -| 你的插件 | `plugins/yourplugin/` | 使用 SDK 脚手架生成 | +| WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入 | --- *了解项目整体目标?查看 [OVERVIEW.md](OVERVIEW.md)。* *了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。* -*SDK 仓库与开发工具?查看 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)。* +*SDK 仓库与开发工具?查看 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)。* \ No newline at end of file diff --git a/go.mod b/go.mod index 53aafbc..2153a8a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,24 @@ module gitcode.com/JianFeeeee/HomeAgent -go 1.19 +go 1.25.0 require ( github.com/mattn/go-sqlite3 v1.14.22 github.com/yuin/gopher-lua v1.1.2 + modernc.org/sqlite v1.53.0 gopkg.in/yaml.v3 v3.0.1 ) require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 // direct + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index 182d206..3ebed40 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,22 @@ gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 h1:DZiZ97SNF1E2EW0vrTl5yDQALk9T8hBq0vcGWCB3m7Q= gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0/go.mod h1:mzs91WBioKDpiMLXZ7t/LXTmAh3DRu3RnDDoSztTcLg= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/go.work b/go.work index 8d98d50..269ee9b 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,6 @@ -go 1.21 +go 1.25.0 use ( . - /tmp/opencode/homeagent-sdk-repo + ../homeagentsdk ) diff --git a/go.work.sum b/go.work.sum index 31e4353..7afe299 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,4 +1,30 @@ +gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0/go.mod h1:mzs91WBioKDpiMLXZ7t/LXTmAh3DRu3RnDDoSztTcLg= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952 h1:FDfvYgoVsA7TTZSbgiqjAbfPbK47CNHdWl3h/PJtii0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 143a090..1c66139 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -609,6 +609,15 @@ func NewProviderManager() *ProviderManager { } } +func (m *ProviderManager) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.providers = make(map[string]Provider) + m.order = nil + m.default_ = "" + m.status = make(map[string]*providerStatus) +} + func (m *ProviderManager) Register(name string, p Provider) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index af1dfde..3309b08 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -2677,6 +2677,35 @@ func (a *Agent) mediaDataURL(defaultMime string) string { return url } +// mediaRequest 构造多模态请求并调用 LLM,统一处理 pendingMedia 检查和 data URL 转换。 +func (a *Agent) mediaRequest(p agentAPI.Provider, mime, emptyPendingMsg, emptyDataMsg, prompt, resultPrefix string, maxTokens int, blockType string, detail string) string { + if a.pendingMedia == nil { + return emptyPendingMsg + } + url := a.mediaDataURL(mime) + if url == "" { + return emptyDataMsg + } + msg := agentAPI.Message{ + Role: "user", + Blocks: []agentAPI.ContentBlock{ + {Type: "text", Text: prompt}, + }, + } + if blockType == "image_url" { + msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{ + Type: "image_url", + ImageURL: &agentAPI.ImageURL{URL: url, Detail: detail}, + }) + } else { + msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{ + Type: "audio_url", + AudioURL: &agentAPI.AudioURL{URL: url}, + }) + } + return a.mediaChat(p, msg, resultPrefix, maxTokens) +} + // mediaChat 调用指定 provider 的多模态 Chat,统一处理超时和错误。 func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) @@ -2693,89 +2722,34 @@ func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefi // executeDescribeImage 调用多模态模型描述当前图片。 func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string { - if a.pendingMedia == nil { - return "没有待处理的图片数据" - } - imgURL := a.mediaDataURL("image/png") - if imgURL == "" { - return "图片数据为空" - } - providerName, _ := tc.Arguments["provider"].(string) p := a.providerManager.Get(providerName) if p == nil { p = a.provider } - - prompt := a.inputCfg.Image.DescribePrompt - if prompt == "" { - prompt = "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。" - } - detail, _ := tc.Arguments["detail"].(string) if detail == "" { detail = "high" } - - msg := agentAPI.Message{ - Role: "user", - Blocks: []agentAPI.ContentBlock{ - {Type: "text", Text: prompt}, - {Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: detail}}, - }, - } - return a.mediaChat(p, msg, "图片描述", 2048) + return a.mediaRequest(p, "image/png", "没有待处理的图片数据", "图片数据为空", + a.inputCfg.Image.DescribePrompt, "图片描述", 2048, "image_url", detail) } // executeTranscribeAudio 调用多模态模型转写/描述当前音频。 func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string { - if a.pendingMedia == nil { - return "没有待处理的音频数据" - } - audURL := a.mediaDataURL("audio/wav") - if audURL == "" { - return "音频数据为空" - } - providerName, _ := tc.Arguments["provider"].(string) p := a.providerManager.Get(providerName) if p == nil { p = a.provider } - - prompt := a.inputCfg.Audio.DescribePrompt - if prompt == "" { - prompt = "请转写这段音频的内容。" - } - - msg := agentAPI.Message{ - Role: "user", - Blocks: []agentAPI.ContentBlock{ - {Type: "text", Text: prompt}, - {Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: audURL}}, - }, - } - return a.mediaChat(p, msg, "音频转写", 2048) + return a.mediaRequest(p, "audio/wav", "没有待处理的音频数据", "音频数据为空", + a.inputCfg.Audio.DescribePrompt, "音频转写", 2048, "audio_url", "") } // executeOCRImage 对图片执行 OCR 文字识别(通过多模态模型实现)。 func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string { - if a.pendingMedia == nil { - return "没有待处理的图片数据" - } - imgURL := a.mediaDataURL("image/png") - if imgURL == "" { - return "图片数据为空" - } - - msg := agentAPI.Message{ - Role: "user", - Blocks: []agentAPI.ContentBlock{ - {Type: "text", Text: "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。"}, - {Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "high"}}, - }, - } - return a.mediaChat(a.provider, msg, "OCR 结果", 4096) + return a.mediaRequest(a.provider, "image/png", "没有待处理的图片数据", "图片数据为空", + a.inputCfg.Image.OCRPrompt, "OCR 结果", 4096, "image_url", "high") } // runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路) diff --git a/internal/config/registry.go b/internal/config/registry.go index 4ecf151..3dffeb5 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -11,7 +11,7 @@ import ( "time" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" ) type ConfigDef struct { @@ -37,7 +37,7 @@ func NewConfigRegistry(dbPath string) *ConfigRegistry { if dbPath == "" { dbPath = ":memory:" } - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite", dbPath) if err != nil { panic(fmt.Sprintf("open config db: %v", err)) } @@ -95,6 +95,75 @@ func (r *ConfigRegistry) GetDef(key string) *ConfigDef { return r.defs[key] } +// sourceFieldDefs 定义 source 类型配置的字段元数据 +var sourceFieldDefs = []struct { + Field string + Type string + DisplayName string +}{ + {"base_url", "string", "API 地址"}, + {"model", "string", "模型"}, + {"api_key", "password", "API 密钥"}, + {"thinking_enabled", "bool", "深度思考"}, + {"adapter", "string", "适配器"}, + {"adapter_path", "string", "适配器路径"}, +} + +// registerSourceDefs 注册 core.llm.sources..* 的 ConfigDef +func (r *ConfigRegistry) registerSourceDefs(name string) { + for _, fd := range sourceFieldDefs { + key := "core.llm.sources." + name + "." + fd.Field + if _, exists := r.defs[key]; exists { + continue + } + r.defs[key] = &ConfigDef{ + Key: key, + Default: "", + Type: fd.Type, + DisplayName: name + " " + fd.DisplayName, + Category: "sources", + } + } +} + +// scanAndRegisterSourceDefsLocked 扫描 config DB 中已有的 core.llm.sources..* 键并注册 defs(调用方已持锁) +func (r *ConfigRegistry) scanAndRegisterSourceDefsLocked() { + seen := make(map[string]bool) + rows, err := r.db.Query(`SELECT key FROM config WHERE key LIKE 'core.llm.sources.%.base_url'`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + continue + } + rest := strings.TrimPrefix(k, "core.llm.sources.") + name := strings.TrimSuffix(rest, ".base_url") + if name == "" || seen[name] { + continue + } + seen[name] = true + r.defsLockedRegisterSource(name) + } +} +func (r *ConfigRegistry) defsLockedRegisterSource(name string) { + for _, fd := range sourceFieldDefs { + key := "core.llm.sources." + name + "." + fd.Field + if _, exists := r.defs[key]; exists { + continue + } + r.defs[key] = &ConfigDef{ + Key: key, + Default: "", + Type: fd.Type, + DisplayName: name + " " + fd.DisplayName, + Category: "sources", + } + } +} + func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef { r.mu.RLock() defer r.mu.RUnlock() @@ -126,6 +195,13 @@ func (r *ConfigRegistry) Set(key string, value interface{}) error { r.mu.Lock() defer r.mu.Unlock() _, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value)) + if err == nil && strings.HasPrefix(key, "core.llm.sources.") { + rest := strings.TrimPrefix(key, "core.llm.sources.") + parts := strings.SplitN(rest, ".", 2) + if len(parts) == 2 && parts[1] != "" { + r.defsLockedRegisterSource(parts[0]) + } + } return err } @@ -197,6 +273,8 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) { defer r.mu.Unlock() r.seedDBValues(dataDir) r.seedCoreDefs(dataDir) + // 扫描 config DB 中已有的 core.llm.sources. 并注册 defs + r.scanAndRegisterSourceDefsLocked() } func (r *ConfigRegistry) seedDBValues(dataDir string) { @@ -270,14 +348,38 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set("core.agent.max_context_size", "30") set("core.agent.distill_interval", "30m") set("core.agent.workdir", "") + set("core.agent.system_prompt", `你是 HomeAgent,一个持续运行的个人管家。 +你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。 +如需切换回复通道,使用 output_set_channel。 +如需异步发送消息或通知,使用 output_send 指定通道和内容。 +使用 output_list_channels 查看可用通道及其能力。 + +可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具: +- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实) +- knowledge_* — 知识库(查阅预设知识文档) +- doc_* — 文档记忆(近期对话的存档,查询后自动清除) +- person_* — 人物特质与社交关系网 +- llm_* — LLM 源管理(列出/切换模型提供商) +- output_* — 输出通道管理(切换/发送消息) +- timer_set — 设置定时提醒 +- plgreload — 热重载插件 +- spawn_child — 生成子 Agent 执行独立任务 +- describe_image — 描述用户上传的图片 +- transcribe_audio — 转写用户上传的音频 +- ocr_image — 识别图片中的文字 + +当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。 + +回复你的真实想法,用自然语言与用户交流。`) 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.describe_prompt", "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。") set("core.input_processing.image.ocr_enabled", "true") + set("core.input_processing.image.ocr_prompt", "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。") set("core.input_processing.audio.fallback_provider", "") set("core.input_processing.audio.fallback_model", "") - set("core.input_processing.audio.describe_prompt", "请描述这段音频的内容") + set("core.input_processing.audio.describe_prompt", "请转写这段音频的内容。") tx.Commit() } @@ -294,7 +396,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { 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.api_key", Default: "", Type: "password", DisplayName: "默认 API 密钥", Description: "默认 LLM API 密钥(空则从环境变量读取)", Placeholder: "留空则使用 LLM_API_KEY 或 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"}) @@ -338,14 +440,16 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { 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.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", 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.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.image.ocr_prompt", Default: "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。", Type: "text", DisplayName: "OCR 提示词", Description: "OCR 文字识别时的系统提示词", 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"}) + reg(ConfigDef{Key: "core.input_processing.audio.describe_prompt", Default: "请转写这段音频的内容。", Type: "text", DisplayName: "音频描述提示词", Description: "生成音频文字描述时的系统提示词", Category: "input"}) } // helpers @@ -522,6 +626,7 @@ func (r *ConfigRegistry) ToConfig() *types.Config { 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.Image.OCRPrompt = read("core.input_processing.image.ocr_prompt", cfg.InputProcessing.Image.OCRPrompt) 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) @@ -587,6 +692,7 @@ func (p *PluginSettings) List(prefix string) ([]string, error) { func (p *PluginSettings) RegisterDef(def ConfigDef) { p.registry.mu.Lock() defer p.registry.mu.Unlock() + p.registry.db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (key, value) VALUES (?, ?)`, p.table), def.Key, def.Default) qualified := "plugin." + p.name + "." + def.Key def.Key = qualified p.registry.defs[def.Key] = &def diff --git a/internal/lua/sdk/embeded.go b/internal/lua/sdk/embeded.go new file mode 100644 index 0000000..1aed0b6 --- /dev/null +++ b/internal/lua/sdk/embeded.go @@ -0,0 +1,8 @@ +package sdk + +import ( + _ "embed" +) + +//go:embed sdk.lua +var SDKSource string diff --git a/internal/lua/sdk/sdk.lua b/internal/lua/sdk/sdk.lua new file mode 100644 index 0000000..cb94e1a --- /dev/null +++ b/internal/lua/sdk/sdk.lua @@ -0,0 +1,204 @@ +-- HomeAgent Lua Plugin SDK +-- Interface contract between Lua plugins and HomeAgent kernel. +-- !impl functions are replaced by Go implementations at runtime. +-- Standalone/debug: pure Lua mock implementations are used. +-- Usage: local sdk = require("sdk") + +sdk = {} + +-- !impl +-- level: "debug" | "info" | "warn" | "error" +function sdk.log(level, msg) + print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) +end + +-- !impl +-- def: { description="...", parameters={...} } +-- handler: function(args) -> result +function sdk.register_tool(name, def, handler) + print("[lua-plugin] register_tool: " .. tostring(name)) +end + +-- !impl +-- stage: "on_input" | "pre_action" | "post_action" | ... +function sdk.register_stage(stage, handler) + print("[lua-plugin] register_stage: " .. tostring(stage)) +end + +-- !impl +function sdk.register_api(name) + print("[lua-plugin] register_api: " .. tostring(name)) +end + +-- !impl +function sdk.get_setting(key) + return nil +end + +-- !impl +function sdk.set_setting(key, value) + print("[lua-plugin] set_setting: " .. tostring(key)) +end + +-- !impl +function sdk.inject_text(source, channel, text) + print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel)) +end + +-- !impl +function sdk.inject_interrupt(source, channel, text) + print("[lua-plugin] inject_interrupt: " .. tostring(source)) +end + +-- !impl +function sdk.inject_text_no_memory(source, channel, text) + print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) +end + +-- json utils (pure Lua) +sdk.json = {} + +function sdk.json.encode(val) + local ok, result = pcall(function() + local function _encode(v) + local t = type(v) + if t == "string" then + local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') + return '"' .. s .. '"' + elseif t == "number" then + return tostring(v) + elseif t == "boolean" then + return tostring(v) + elseif t == "table" then + local keys = {} + local is_array = true + local maxn = 0 + for k in pairs(v) do + keys[#keys + 1] = k + if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then + is_array = false + end + if type(k) == "number" and k > maxn then maxn = k end + end + if is_array and #keys >= maxn then + local parts = {} + for i = 1, maxn do + parts[#parts + 1] = _encode(v[i]) + end + return "[" .. table.concat(parts, ",") .. "]" + else + local parts = {} + for _, k in ipairs(keys) do + parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k]) + end + return "{" .. table.concat(parts, ",") .. "}" + end + else + return "null" + end + end + return _encode(val) + end) + if ok then return result end + return "null" +end + +function sdk.json.decode(str) + local ok, result = pcall(function() + local pos, _end = 1, #str + local function skip() + while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end + end + local function parse() + skip() + if pos > _end then return nil end + local c = str:sub(pos, pos) + if c == '"' then + local s = {} + pos = pos + 1 + while pos <= _end do + local ch = str:sub(pos, pos) + if ch == '"' then + pos = pos + 1 + return table.concat(s) + elseif ch == '\\' then + pos = pos + 1 + local n = str:sub(pos, pos) + if n == '"' then s[#s+1] = '"' + elseif n == '\\' then s[#s+1] = '\\' + elseif n == '/' then s[#s+1] = '/' + elseif n == 'b' then s[#s+1] = '\b' + elseif n == 'f' then s[#s+1] = '\f' + elseif n == 'n' then s[#s+1] = '\n' + elseif n == 'r' then s[#s+1] = '\r' + elseif n == 't' then s[#s+1] = '\t' + elseif n == 'u' then + local hex = str:sub(pos+1, pos+4) + pos = pos + 4 + s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?' + end + pos = pos + 1 + else + s[#s+1] = ch + pos = pos + 1 + end + end + return table.concat(s) + elseif c == 't' then pos = pos + 4; return true + elseif c == 'f' then pos = pos + 5; return false + elseif c == 'n' then pos = pos + 4; return nil + elseif c == '{' then + pos = pos + 1; skip() + local t = {} + if str:sub(pos, pos) == '}' then pos = pos + 1; return t end + while true do + skip(); local k = parse(); skip() + if str:sub(pos, pos) == ':' then pos = pos + 1 end + skip(); t[k] = parse(); skip() + local sep = str:sub(pos, pos) + if sep == '}' then pos = pos + 1; return t end + if sep == ',' then pos = pos + 1 end + end + elseif c == '[' then + pos = pos + 1; skip() + local t = {} + if str:sub(pos, pos) == ']' then pos = pos + 1; return t end + local idx = 1 + while true do + skip(); t[idx] = parse(); idx = idx + 1; skip() + local sep = str:sub(pos, pos) + if sep == ']' then pos = pos + 1; return t end + if sep == ',' then pos = pos + 1 end + end + else + local s, e = str:find('^[-%d%.eE]+', pos) + if s then + local num = tonumber(str:sub(s, e)) + pos = e + 1 + return num + end + return nil + end + end + return parse() + end) + if ok then return result end + return nil +end + +-- http utils +sdk.http = {} + +-- !impl +function sdk.http.get(url) + print("[lua-plugin] http.get: " .. tostring(url)) + return {status=200, body='{"mock":true}', headers={}} +end + +-- !impl +function sdk.http.post(url, body, content_type) + print("[lua-plugin] http.post: " .. tostring(url)) + return {status=200, body='{"mock":true}', headers={}} +end + +return sdk diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 1a9cb2b..106bdeb 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -4,6 +4,7 @@ import ( "embed" "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "sync" @@ -14,49 +15,37 @@ import ( //go:embed adapters/*.lua var bundledAdapters embed.FS -type VM struct { - mu sync.Mutex - state *lua.LState - adapterDir string - loaded map[string]*lua.LTable -} - type APIAdapter struct { - Name string - Version string - Script string + Name string `json:"name"` + Version string `json:"version"` } -func (v *VM) AdapterDir() string { - return v.adapterDir +// AdapterCache 预加载容器:启动时编译全部脚本到内存,运行时只读缓存,无文件 I/O +type AdapterCache struct { + mu sync.RWMutex + state *lua.LState + items map[string]*lua.LTable } -func NewVM(adapterDir string) *VM { - return &VM{ - adapterDir: adapterDir, - loaded: make(map[string]*lua.LTable), +func newAdapterCache() *AdapterCache { + return &AdapterCache{ + state: lua.NewState(), + items: make(map[string]*lua.LTable), } } -func (v *VM) Start() error { - os.MkdirAll(v.adapterDir, 0755) - - if err := v.writeBundledAdapters(); err != nil { - return fmt.Errorf("write bundled adapters: %w", err) - } - - v.state = lua.NewState() - - v.state.SetGlobal("log", v.state.NewFunction(func(L *lua.LState) int { +func (c *AdapterCache) setupGlobals() { + s := c.state + s.SetGlobal("log", s.NewFunction(func(L *lua.LState) int { level := L.ToString(1) msg := L.ToString(2) fmt.Printf("[lua/%s] %s\n", level, msg) return 0 })) - jsonTable := v.state.NewTable() - v.state.SetGlobal("json", jsonTable) - v.state.SetField(jsonTable, "encode", v.state.NewFunction(func(L *lua.LState) int { + jsonTable := s.NewTable() + s.SetGlobal("json", jsonTable) + s.SetField(jsonTable, "encode", s.NewFunction(func(L *lua.LState) int { val := L.CheckAny(1) goVal := luaValueToGo(val) b, err := json.Marshal(goVal) @@ -67,7 +56,7 @@ func (v *VM) Start() error { L.Push(lua.LString(string(b))) return 1 })) - v.state.SetField(jsonTable, "decode", v.state.NewFunction(func(L *lua.LState) int { + s.SetField(jsonTable, "decode", s.NewFunction(func(L *lua.LState) int { str := L.CheckString(1) var val interface{} if err := json.Unmarshal([]byte(str), &val); err != nil { @@ -78,33 +67,120 @@ func (v *VM) Start() error { return 1 })) - v.state.SetGlobal("http_get", v.state.NewFunction(func(L *lua.LState) int { + s.SetGlobal("http_get", s.NewFunction(func(L *lua.LState) int { url := L.ToString(1) L.Push(lua.LString(fmt.Sprintf(`{"url":%q,"status":200,"body":"mock"}`, url))) return 1 })) - v.state.SetGlobal("http_post", v.state.NewFunction(func(L *lua.LState) int { + s.SetGlobal("http_post", s.NewFunction(func(L *lua.LState) int { url := L.ToString(1) body := L.ToString(2) L.Push(lua.LString(fmt.Sprintf(`{"url":%q,"body":%q,"status":200}`, url, body))) return 1 })) +} - if err := v.loadAdapters(); err != nil { - return fmt.Errorf("load adapters: %w", err) +// Preload 编译单个 Lua 适配器脚本并注入缓存 +func (c *AdapterCache) Preload(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read adapter: %w", err) + } + return c.PreloadSource(filepath.Base(path), string(data)) +} + +// PreloadSource 从源码字符串编译适配器并注入缓存 +func (c *AdapterCache) PreloadSource(name, code string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.state.DoString(code); err != nil { + return fmt.Errorf("compile adapter: %w", err) } + tbl, ok := c.state.Get(-1).(*lua.LTable) + c.state.Pop(1) + if !ok { + return fmt.Errorf("adapter script must return a table") + } + + if n := tbl.RawGetString("name"); n != nil && n.String() != "" { + name = n.String() + } + + c.items[name] = tbl return nil } -func (v *VM) Stop() { - if v.state != nil { - v.state.Close() +// Get 运行时从缓存读取已编译的适配器表(无文件 I/O) +func (c *AdapterCache) Get(name string) *lua.LTable { + c.mu.RLock() + defer c.mu.RUnlock() + return c.items[name] +} + +// Remove 从缓存移除适配器 +func (c *AdapterCache) Remove(name string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.items, name) +} + +// List 返回缓存中所有适配器摘要 +func (c *AdapterCache) List() []APIAdapter { + c.mu.RLock() + defer c.mu.RUnlock() + list := make([]APIAdapter, 0, len(c.items)) + for name, tbl := range c.items { + a := APIAdapter{Name: name} + if v := tbl.RawGetString("version"); v != nil { + a.Version = v.String() + } + list = append(list, a) + } + return list +} + +// Close 释放 Lua 状态 +func (c *AdapterCache) Close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.state != nil { + c.state.Close() + c.state = nil + } + c.items = nil +} + +// VM 运行时虚拟机,封装 AdapterCache 提供适配器调用 +type VM struct { + mu sync.Mutex + cache *AdapterCache + adapterDir string +} + +func NewVM(adapterDir string) *VM { + return &VM{ + adapterDir: adapterDir, + cache: newAdapterCache(), } } -func (v *VM) loadAdapters() error { +func (v *VM) AdapterDir() string { return v.adapterDir } +func (v *VM) Cache() *AdapterCache { return v.cache } + +func (v *VM) Start() error { + if err := os.MkdirAll(v.adapterDir, 0755); err != nil { + return fmt.Errorf("mkdir adapter dir: %w", err) + } + if err := v.writeBundledAdapters(); err != nil { + return fmt.Errorf("write bundled adapters: %w", err) + } + + v.cache.setupGlobals() + + // 预加载:扫描适配器目录,全部编译到缓存 entries, err := os.ReadDir(v.adapterDir) if err != nil { if os.IsNotExist(err) { @@ -112,134 +188,108 @@ func (v *VM) loadAdapters() error { } return err } - for _, entry := range entries { if filepath.Ext(entry.Name()) != ".lua" { continue } path := filepath.Join(v.adapterDir, entry.Name()) - if err := v.LoadAdapter(path); err != nil { - fmt.Printf("[lua] load %s: %v\n", entry.Name(), err) + if err := v.cache.Preload(path); err != nil { + fmt.Printf("[lua] preload %s: %v\n", entry.Name(), err) } } - return nil } +func (v *VM) Stop() { + v.cache.Close() +} + +// LoadAdapter 对外接口:从文件加载并编译适配器到缓存(运行时安全,不影响其他适配器) func (v *VM) LoadAdapter(path string) error { - v.mu.Lock() - defer v.mu.Unlock() + return v.cache.Preload(path) +} - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read adapter: %w", err) - } +// RemoveAdapter 对外接口:从缓存移除适配器(运行时安全) +func (v *VM) RemoveAdapter(name string) { + v.cache.Remove(name) +} - script := string(data) - - if err := v.state.DoString(script); err != nil { - return fmt.Errorf("execute adapter script: %w", err) - } - - adapterTable := v.state.Get(-1) - v.state.Pop(1) - - tbl, ok := adapterTable.(*lua.LTable) - if !ok { - return fmt.Errorf("adapter script must return a table") - } - - name := "" - if nameVal := tbl.RawGetString("name"); nameVal != nil { - name = nameVal.String() - } - if name == "" { - name = filepath.Base(path) - } - - v.loaded[name] = tbl - fmt.Printf("[lua] loaded adapter: %s\n", name) - return nil +func (v *VM) ListAdapters() []APIAdapter { + return v.cache.List() } func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - - adapter, ok := v.loaded[name] - if !ok { + adapter := v.cache.Get(name) + if adapter == nil { return "", fmt.Errorf("adapter %s not loaded", name) } + v.mu.Lock() + defer v.mu.Unlock() + fn := adapter.RawGetString("transform_request") if fn == nil { return "", fmt.Errorf("adapter %s missing transform_request", name) } - v.state.Push(fn) - v.state.Push(lua.LString(rawJSON)) - - if err := v.state.PCall(1, 1, nil); err != nil { + state := v.cache.state + state.Push(fn) + state.Push(lua.LString(rawJSON)) + if err := state.PCall(1, 1, nil); err != nil { return "", fmt.Errorf("transform_request: %w", err) } - - result := v.state.Get(-1) - v.state.Pop(1) - + result := state.Get(-1) + state.Pop(1) return result.String(), nil } func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - - adapter, ok := v.loaded[name] - if !ok { + adapter := v.cache.Get(name) + if adapter == nil { return rawJSON, nil } + v.mu.Lock() + defer v.mu.Unlock() + fn := adapter.RawGetString("transform_response") if fn == nil { return rawJSON, nil } - v.state.Push(fn) - v.state.Push(lua.LString(rawJSON)) - - if err := v.state.PCall(1, 1, nil); err != nil { + state := v.cache.state + state.Push(fn) + state.Push(lua.LString(rawJSON)) + if err := state.PCall(1, 1, nil); err != nil { return "", fmt.Errorf("transform_response: %w", err) } - - result := v.state.Get(-1) - v.state.Pop(1) - + result := state.Get(-1) + state.Pop(1) return result.String(), nil } func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - - adapter, ok := v.loaded[name] - if !ok { + adapter := v.cache.Get(name) + if adapter == nil { return rawLine, nil } + v.mu.Lock() + defer v.mu.Unlock() + fn := adapter.RawGetString("transform_stream_chunk") if fn == nil { return rawLine, nil } - v.state.Push(fn) - v.state.Push(lua.LString(rawLine)) - - if err := v.state.PCall(1, 1, nil); err != nil { + state := v.cache.state + state.Push(fn) + state.Push(lua.LString(rawLine)) + if err := state.PCall(1, 1, nil); err != nil { return "", fmt.Errorf("transform_stream_chunk: %w", err) } - - result := v.state.Get(-1) - v.state.Pop(1) - + result := state.Get(-1) + state.Pop(1) if result.String() == "" { return "", nil } @@ -247,14 +297,10 @@ func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) { } func (v *VM) GetAdapterEndpoint(name string) string { - v.mu.Lock() - defer v.mu.Unlock() - - adapter, ok := v.loaded[name] - if !ok { + adapter := v.cache.Get(name) + if adapter == nil { return "" } - if ep := adapter.RawGetString("endpoint"); ep != nil { return ep.String() } @@ -262,14 +308,10 @@ func (v *VM) GetAdapterEndpoint(name string) string { } func (v *VM) GetAdapterHeaders(name string) map[string]string { - v.mu.Lock() - defer v.mu.Unlock() - - adapter, ok := v.loaded[name] - if !ok { + adapter := v.cache.Get(name) + if adapter == nil { return nil } - headers := make(map[string]string) if ht := adapter.RawGetString("headers"); ht != nil { if tbl, ok := ht.(*lua.LTable); ok { @@ -281,32 +323,45 @@ func (v *VM) GetAdapterHeaders(name string) map[string]string { return headers } -func (v *VM) ListAdapters() []APIAdapter { - v.mu.Lock() - defer v.mu.Unlock() - - adapters := make([]APIAdapter, 0) - for name, tbl := range v.loaded { - adapter := APIAdapter{Name: name} - if v := tbl.RawGetString("version"); v != nil { - adapter.Version = v.String() +func (v *VM) writeBundledAdapters() error { + // Try multiple paths for compatibility + tryPaths := []string{"adapters", ".", "lua/adapters"} + var entries []fs.DirEntry + var err error + for _, p := range tryPaths { + entries, err = bundledAdapters.ReadDir(p) + if err == nil && len(entries) > 0 { + break } - adapters = append(adapters, adapter) } - return adapters -} - -func (v *VM) ReloadAll() error { - v.mu.Lock() - v.loaded = make(map[string]*lua.LTable) - v.mu.Unlock() - - if v.state != nil { - v.state.Close() + if err != nil || len(entries) == 0 { + return nil } - v.state = lua.NewState() - - return v.Start() + for _, entry := range entries { + if entry.IsDir() { + continue + } + if filepath.Ext(entry.Name()) != ".lua" { + continue + } + dstPath := filepath.Join(v.adapterDir, entry.Name()) + if _, err := os.Stat(dstPath); err == nil { + continue + } + data, err := bundledAdapters.ReadFile(filepath.Join("adapters", entry.Name())) + if err != nil { + // try alternative paths + data, err = bundledAdapters.ReadFile(entry.Name()) + if err != nil { + continue + } + } + if err := os.WriteFile(dstPath, data, 0644); err != nil { + return fmt.Errorf("write %s: %w", entry.Name(), err) + } + fmt.Printf("[lua] installed bundled adapter: %s\n", entry.Name()) + } + return nil } func luaValueToGo(lv lua.LValue) interface{} { @@ -365,33 +420,3 @@ func goValueToLua(L *lua.LState, val interface{}) lua.LValue { return lua.LNil } } - -func (v *VM) writeBundledAdapters() error { - entries, err := bundledAdapters.ReadDir("adapters") - if err != nil { - return nil - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - dstPath := filepath.Join(v.adapterDir, entry.Name()) - if _, err := os.Stat(dstPath); err == nil { - continue - } - - data, err := bundledAdapters.ReadFile(filepath.Join("adapters", entry.Name())) - if err != nil { - continue - } - - if err := os.WriteFile(dstPath, data, 0644); err != nil { - return fmt.Errorf("write %s: %w", entry.Name(), err) - } - fmt.Printf("[lua] installed bundled adapter: %s\n", entry.Name()) - } - return nil -} - - diff --git a/internal/memory/graph.go b/internal/memory/graph.go index f878f2c..153871c 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -6,7 +6,7 @@ import ( "sync" "time" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" ) type Entity struct { @@ -49,7 +49,7 @@ type GraphDB struct { } func NewGraphDB(dbPath string) (*GraphDB, error) { - db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on") + db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_foreign_keys=on") if err != nil { return nil, fmt.Errorf("open graph db: %w", err) } @@ -491,6 +491,69 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { return int(n), nil } +func (g *GraphDB) GraphData() (map[string]interface{}, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + rows, err := g.db.Query(`SELECT id, name, type, mention_count, created_at, updated_at FROM entities ORDER BY mention_count DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + type graphEntity struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + MentionCount int `json:"mention_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + var entities []graphEntity + for rows.Next() { + var e graphEntity + if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + entities = append(entities, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + + rrows, err := g.db.Query(`SELECT id, source_id, target_id, relation_type, confidence, status, created_at FROM relations WHERE status = 'active' ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rrows.Close() + + type graphRelation struct { + ID int64 `json:"id"` + SourceID int64 `json:"source_id"` + TargetID int64 `json:"target_id"` + RelationType string `json:"relation_type"` + Confidence float64 `json:"confidence"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + } + var relations []graphRelation + for rrows.Next() { + var r graphRelation + if err := rrows.Scan(&r.ID, &r.SourceID, &r.TargetID, &r.RelationType, &r.Confidence, &r.Status, &r.CreatedAt); err != nil { + return nil, err + } + relations = append(relations, r) + } + if err := rrows.Err(); err != nil { + return nil, err + } + + return map[string]interface{}{ + "nodes": entities, + "edges": relations, + }, nil +} + func (g *GraphDB) Introspect() (map[string]interface{}, error) { g.mu.RLock() defer g.mu.RUnlock() diff --git a/internal/plugin/bridge_e2e_test.go b/internal/plugin/bridge_e2e_test.go new file mode 100644 index 0000000..822bbc3 --- /dev/null +++ b/internal/plugin/bridge_e2e_test.go @@ -0,0 +1,263 @@ +//go:build windows + +package plugin + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "syscall" + "testing" + "unsafe" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func TestBridgeE2E_WebPlugin(t *testing.T) { + exeDir, _ := os.Executable() + // Find web example build relative to the homeagent repo root + haRoot := findHomeAgentRoot(t, exeDir) + dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "web", "build", "plugin.dll") + if _, err := os.Stat(dllPath); os.IsNotExist(err) { + t.Fatalf("web plugin DLL not found at %s\nRun: cd example/web && plugindev build --target windows/amd64", dllPath) + } + + // Track captured tools and stages + var capturedTools []sdk.ToolDef + var capturedStages []sdk.Stage + + regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + capturedTools = append(capturedTools, def) + t.Logf(" registered tool: %s", name) + return nil + } + regStage := func(stage sdk.Stage, handler sdk.StageHandler) { + capturedStages = append(capturedStages, stage) + t.Logf(" registered stage: %s", stage) + } + regAPI := func(name string) error { + t.Logf(" registered API: %s", name) + return nil + } + + sett := sdk.NewSettings("web", nil) + psdk := sdk.New("web", nil, nil, nil, nil, nil, nil, nil, sett, regTool, regStage, regAPI) + + plg, err := newDLLPlugin(dllPath, "web", nil) + if err != nil { + t.Fatalf("newDLLPlugin failed: %v", err) + } + defer plg.Stop() + + // Start — this calls NewPlugin + StartPlugin + registerTools + registerStages + if err := plg.Start(psdk); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Verify tools were captured + if len(capturedTools) == 0 { + t.Fatal("no tools were registered by web plugin") + } + t.Logf("Captured %d tools:", len(capturedTools)) + for _, d := range capturedTools { + t.Logf(" - %s: %s", d.Name, d.Description[:min(len(d.Description), 60)]) + } + + // Check specific expected tools + webSearch, webFetch := false, false + for _, d := range capturedTools { + if d.Name == "web_search" { + webSearch = true + if d.Description == "" { + t.Error("web_search has empty description") + } + params := d.Parameters + if params == nil { + t.Error("web_search has nil parameters") + } else { + if _, ok := params["properties"]; !ok { + t.Error("web_search parameters missing 'properties'") + } + } + } + if d.Name == "web_fetch" { + webFetch = true + } + } + if !webSearch { + t.Error("expected tool 'web_search' not registered") + } + if !webFetch { + t.Error("expected tool 'web_fetch' not registered") + } + + // Verify bridge exports work via direct C ABI calls + t.Logf("Bridge exports: getTools=%x invokeTool=%x freeCStr=%x", + plg.getTools, plg.invokeTool, plg.freeCStr) + + // GetToolDefsJSON + if plg.getTools != 0 { + toolDefsJSON := callGetToolDefsJSON(t, plg) + if len(toolDefsJSON) == 0 { + t.Error("GetToolDefsJSON returned empty array, expected tools") + } + for _, d := range toolDefsJSON { + t.Logf(" bridge tool: %s", d["name"]) + } + } + + // InvokeToolJSON — test with the search tool + if plg.invokeTool != 0 { + result := callInvokeToolJSON(t, plg, "web_search", map[string]interface{}{ + "query": "test", + "count": 1, + }) + t.Logf("InvokeToolJSON result keys: %v", keysOfMap(result)) + // Should get a result map (might be error if no network, but should not crash) + if errStr, ok := result["error"]; ok { + t.Logf(" (expected — tool returned error: %v)", errStr) + } + } +} + +func TestBridgeE2E_SanitizerStages(t *testing.T) { + exeDir, _ := os.Executable() + haRoot := findHomeAgentRoot(t, exeDir) + dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "sanitizer", "build", "plugin.dll") + if _, err := os.Stat(dllPath); os.IsNotExist(err) { + t.Skip("sanitizer DLL not built") + } + + var capturedStages []sdk.Stage + regStage := func(stage sdk.Stage, handler sdk.StageHandler) { + capturedStages = append(capturedStages, stage) + t.Logf(" registered stage: %s", stage) + } + + sett := sdk.NewSettings("sanitizer", nil) + psdk := sdk.New("sanitizer", nil, nil, nil, nil, nil, nil, nil, sett, + func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil }, + regStage, + func(name string) error { return nil }, + ) + + plg, err := newDLLPlugin(dllPath, "sanitizer", nil) + if err != nil { + t.Fatalf("newDLLPlugin failed: %v", err) + } + defer plg.Stop() + + if err := plg.Start(psdk); err != nil { + t.Fatalf("Start failed: %v", err) + } + + if len(capturedStages) == 0 { + t.Fatal("no stages registered by sanitizer") + } + found := false + for _, s := range capturedStages { + if s == sdk.StagePostAction { + found = true + } + } + if !found { + t.Fatalf("expected post_action stage, got %v", capturedStages) + } + + // Verify bridge GetStagesJSON + if plg.getStages != 0 { + ret, _, _ := syscall.SyscallN(plg.getStages, plg.handle) + if ret != 0 { + stagesJSON := cStringPtrToString(ret) + if plg.freeCStr != 0 { + syscall.SyscallN(plg.freeCStr, ret) + } + t.Logf("GetStagesJSON: %s", stagesJSON) + if !contains(t, stagesJSON, "post_action") { + t.Error("GetStagesJSON missing post_action") + } + } + } +} + +// --- helpers --- + +func findHomeAgentRoot(t *testing.T, exeDir string) string { + t.Helper() + // Walk up from test binary directory looking for homeagent/ + dir := exeDir + for i := 0; i < 10; i++ { + if _, err := os.Stat(filepath.Join(dir, "internal", "plugin")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatal("cannot find homeagent root") + return "" +} + +func callGetToolDefsJSON(t *testing.T, plg *dllPlugin) []map[string]interface{} { + t.Helper() + ret, _, _ := syscall.SyscallN(plg.getTools, plg.handle) + if ret == 0 { + t.Fatal("GetToolDefsJSON returned nil") + } + jsonStr := cStringPtrToString(ret) + if plg.freeCStr != 0 { + syscall.SyscallN(plg.freeCStr, ret) + } + var defs []map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &defs); err != nil { + t.Fatalf("GetToolDefsJSON parse error: %v", err) + } + return defs +} + +func callInvokeToolJSON(t *testing.T, plg *dllPlugin, toolName string, args map[string]interface{}) map[string]interface{} { + t.Helper() + argsJSON, _ := json.Marshal(args) + cToolName := append([]byte(toolName), 0) + cArgs := append(argsJSON, 0) + + ret, _, _ := syscall.SyscallN( + plg.invokeTool, + plg.handle, + uintptr(unsafe.Pointer(&cToolName[0])), + uintptr(unsafe.Pointer(&cArgs[0])), + ) + if ret == 0 { + t.Fatal("InvokeToolJSON returned nil") + } + jsonStr := cStringPtrToString(ret) + if plg.freeCStr != 0 { + syscall.SyscallN(plg.freeCStr, ret) + } + var result map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + t.Fatalf("InvokeToolJSON parse error: %v (json=%s)", err, jsonStr) + } + return result +} + +func keysOfMap(m map[string]interface{}) []string { + var keys []string + for k := range m { + keys = append(keys, k) + } + return keys +} + +func contains(t *testing.T, s, substr string) bool { + t.Helper() + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go index c7432ed..f80bae5 100644 --- a/internal/plugin/dynamic.go +++ b/internal/plugin/dynamic.go @@ -21,6 +21,7 @@ import ( // } const ( soEntry = "plugin.so" + dllEntry = "plugin.dll" luaEntry = "main.lua" metaEntry = "plugin.json" ) @@ -112,15 +113,3 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err return &dynamicPlugin{name: name, impl: plg}, nil } - -// tryLoadLua 尝试从插件目录加载 main.lua(Lua 插件)。 -// 返回 nil,nil 表示目录中没有 main.lua。 -func tryLoadLua(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { - luaPath := filepath.Join(dir, luaEntry) - if _, err := os.Stat(luaPath); os.IsNotExist(err) { - return nil, nil - } - - // 预留:Lua 插件需在 LuaVM 中注册一个 LuaPlugin 包装器 - return nil, fmt.Errorf("lua plugin loading not yet implemented: %s", name) -} diff --git a/internal/plugin/dynamic_dll_stub.go b/internal/plugin/dynamic_dll_stub.go new file mode 100644 index 0000000..5fbcae6 --- /dev/null +++ b/internal/plugin/dynamic_dll_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package plugin + +import ( + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { + return nil, nil +} diff --git a/internal/plugin/dynamic_dll_test.go b/internal/plugin/dynamic_dll_test.go new file mode 100644 index 0000000..a97f822 --- /dev/null +++ b/internal/plugin/dynamic_dll_test.go @@ -0,0 +1,32 @@ +//go:build windows + +package plugin + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTryLoadDLL_NoFile(t *testing.T) { + dir := t.TempDir() + plg, err := tryLoadDLL(dir, "nonexistent", nil) + if err != nil { + t.Fatalf("tryLoadDLL on empty dir should not error: %v", err) + } + if plg != nil { + t.Fatal("expected nil for non-existent plugin.dll") + } +} + +func TestTryLoadDLL_Invalid(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "plugin.dll"), []byte("not a real dll"), 0644) + + plg, err := tryLoadDLL(dir, "baddll", nil) + t.Logf("plg=%v err=%v", plg, err) + + if err == nil && plg == nil { + t.Fatal("expected error or non-nil plugin for existing file") + } +} diff --git a/internal/plugin/dynamic_dll_windows.go b/internal/plugin/dynamic_dll_windows.go new file mode 100644 index 0000000..4b73b72 --- /dev/null +++ b/internal/plugin/dynamic_dll_windows.go @@ -0,0 +1,272 @@ +//go:build windows + +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + "unsafe" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// dllPlugin wraps a Windows DLL compiled with -buildmode=c-shared. +// +// Required exports: +// +// NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer → plugin handle +// StartPlugin(handle unsafe.Pointer) C.int +// StopPlugin(handle unsafe.Pointer) C.int +// DestroyPlugin(handle unsafe.Pointer) +// +// Optional exports (tool registration): +// +// GetToolDefsJSON(handle unsafe.Pointer) *C.char → JSON array of tool defs +// InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char +// FreeCString(s *C.char) → free C string from DLL +// GetStagesJSON(handle unsafe.Pointer) *C.char → JSON array of stage names +// InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int +type dllPlugin struct { + name string + dll syscall.Handle + handle uintptr + sdk *sdk.PluginSDK + + // cached proc addresses + newPlugin uintptr + startPlugin uintptr + stopPlugin uintptr + destroyPlugin uintptr + getTools uintptr + invokeTool uintptr + freeCStr uintptr + getStages uintptr + invokeStage uintptr +} + +func findProc(dll syscall.Handle, name string) uintptr { + addr, err := syscall.GetProcAddress(dll, name) + if err != nil { + return 0 + } + return addr +} + +func newDLLPlugin(dllPath, name string, config map[string]interface{}) (*dllPlugin, error) { + dll, err := syscall.LoadLibrary(dllPath) + if err != nil { + return nil, fmt.Errorf("LoadLibrary %s: %w", dllPath, err) + } + + np := findProc(dll, "NewPlugin") + if np == 0 { + _ = syscall.FreeLibrary(dll) + return nil, fmt.Errorf("dll %s must export NewPlugin", name) + } + + return &dllPlugin{ + name: name, + dll: dll, + // required + newPlugin: np, + startPlugin: findProc(dll, "StartPlugin"), + stopPlugin: findProc(dll, "StopPlugin"), + destroyPlugin: findProc(dll, "DestroyPlugin"), + // optional tool/stage API + getTools: findProc(dll, "GetToolDefsJSON"), + invokeTool: findProc(dll, "InvokeToolJSON"), + freeCStr: findProc(dll, "FreeCString"), + getStages: findProc(dll, "GetStagesJSON"), + invokeStage: findProc(dll, "InvokeStage"), + }, nil +} + +func (p *dllPlugin) Name() string { return p.name } + +func (p *dllPlugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + + cfgJSON, _ := json.Marshal(map[string]interface{}{ + "name": p.name, + "config": s.Settings().Dump(), + }) + cName := append([]byte(p.name), 0) + cConfig := append(cfgJSON, 0) + + ret, _, _ := syscall.SyscallN( + p.newPlugin, + uintptr(unsafe.Pointer(&cName[0])), + uintptr(unsafe.Pointer(&cConfig[0])), + ) + if ret == 0 { + _ = syscall.FreeLibrary(p.dll) + return fmt.Errorf("dll NewPlugin %s returned nil", p.name) + } + p.handle = ret + + if p.startPlugin != 0 { + syscall.SyscallN(p.startPlugin, p.handle) + } + + // discover and register tools from DLL + if p.getTools != 0 { + if err := p.registerTools(s); err != nil { + return fmt.Errorf("dll %s register tools: %w", p.name, err) + } + } + if p.getStages != 0 { + p.registerStages(s) + } + return nil +} + +func (p *dllPlugin) Stop() error { + if p.stopPlugin != 0 { + syscall.SyscallN(p.stopPlugin, p.handle) + } + if p.destroyPlugin != 0 { + syscall.SyscallN(p.destroyPlugin, p.handle) + } + _ = syscall.FreeLibrary(p.dll) + return nil +} + +// --- tool registration via C ABI --- + +type dllToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters,omitempty"` +} + +func (p *dllPlugin) registerTools(s *sdk.PluginSDK) error { + ret, _, _ := syscall.SyscallN(p.getTools, p.handle) + if ret == 0 { + return nil // no tools + } + defsJSON := cStringPtrToString(ret) + if p.freeCStr != 0 { + syscall.SyscallN(p.freeCStr, ret) + } + + var defs []dllToolDef + if err := json.Unmarshal([]byte(defsJSON), &defs); err != nil { + return fmt.Errorf("parse tool defs: %w", err) + } + for _, d := range defs { + if d.Name == "" { + continue + } + toolName := d.Name + handler := p.makeToolHandler(toolName) + s.RegisterTool(toolName, sdk.ToolDef{ + Name: toolName, + Description: d.Description, + Parameters: d.Parameters, + Plugin: p.name, + }, handler) + } + return nil +} + +func (p *dllPlugin) makeToolHandler(toolName string) sdk.ToolHandler { + return func(args map[string]interface{}) (interface{}, error) { + if p.invokeTool == 0 { + return nil, fmt.Errorf("dll %s does not export InvokeToolJSON", p.name) + } + argsJSON, _ := json.Marshal(args) + cToolName := append([]byte(toolName), 0) + cArgs := append(argsJSON, 0) + + ret, _, _ := syscall.SyscallN( + p.invokeTool, + p.handle, + uintptr(unsafe.Pointer(&cToolName[0])), + uintptr(unsafe.Pointer(&cArgs[0])), + ) + if ret == 0 { + return nil, fmt.Errorf("dll InvokeToolJSON %s returned nil", toolName) + } + resultJSON := cStringPtrToString(ret) + if p.freeCStr != 0 { + syscall.SyscallN(p.freeCStr, ret) + } + var result map[string]interface{} + if err := json.Unmarshal([]byte(resultJSON), &result); err != nil { + return nil, fmt.Errorf("dll tool %s result parse: %w", toolName, err) + } + return result, nil + } +} + +func (p *dllPlugin) registerStages(s *sdk.PluginSDK) { + ret, _, _ := syscall.SyscallN(p.getStages, p.handle) + if ret == 0 { + return + } + stagesJSON := cStringPtrToString(ret) + if p.freeCStr != 0 { + syscall.SyscallN(p.freeCStr, ret) + } + type stageEntry struct { + Stage string `json:"stage"` + } + var entries []stageEntry + if err := json.Unmarshal([]byte(stagesJSON), &entries); err != nil { + return + } + for _, e := range entries { + if e.Stage == "" { + continue + } + stageName := sdk.Stage(e.Stage) + stage := stageName + s.RegisterStage(stage, func(sc *sdk.StageContext) error { + if p.invokeStage == 0 { + return nil + } + ctxJSON, _ := json.Marshal(map[string]interface{}{ + "raw_message": sc.RawMessage, + "user_id": sc.UserID, + "phase": string(sc.Phase), + }) + cStage := append([]byte(stage), 0) + cCtx := append(ctxJSON, 0) + syscall.SyscallN( + p.invokeStage, + p.handle, + uintptr(unsafe.Pointer(&cStage[0])), + uintptr(unsafe.Pointer(&cCtx[0])), + ) + return nil + }) + } +} + +func cStringPtrToString(ptr uintptr) string { + if ptr == 0 { + return "" + } + var buf []byte + for i := uintptr(0); ; i++ { + b := *(*byte)(unsafe.Pointer(ptr + i)) + if b == 0 { + break + } + buf = append(buf, b) + } + return string(buf) +} + +// tryLoadDLL 尝试从插件目录加载 plugin.dll。 +// 返回 nil,nil 表示目录中没有 plugin.dll。 +func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { + dllPath := filepath.Join(dir, dllEntry) + if _, err := os.Stat(dllPath); os.IsNotExist(err) { + return nil, nil + } + return newDLLPlugin(dllPath, name, config) +} diff --git a/internal/plugin/dynamic_lua.go b/internal/plugin/dynamic_lua.go new file mode 100644 index 0000000..86b793b --- /dev/null +++ b/internal/plugin/dynamic_lua.go @@ -0,0 +1,24 @@ +package plugin + +import ( + "fmt" + "os" + "path/filepath" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// tryLoadLua 从插件目录加载 main.lua(Lua 插件)。 +// 返回 nil,nil 表示目录中没有 main.lua。 +func tryLoadLua(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { + luaPath := filepath.Join(dir, luaEntry) + if _, err := os.Stat(luaPath); os.IsNotExist(err) { + return nil, nil + } + + plg, err := newLuaPlugin(luaPath, name) + if err != nil { + return nil, fmt.Errorf("lua plugin %s: %w", name, err) + } + return plg, nil +} diff --git a/internal/plugin/lua_plugin.go b/internal/plugin/lua_plugin.go new file mode 100644 index 0000000..8d11031 --- /dev/null +++ b/internal/plugin/lua_plugin.go @@ -0,0 +1,347 @@ +package plugin + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" + + lua "github.com/yuin/gopher-lua" + luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +type toolReg struct { + def sdk.ToolDef + handler *lua.LFunction +} + +// luaPlugin wraps a Lua script as an sdk.Plugin. +type luaPlugin struct { + name string + L *lua.LState + tbl *lua.LTable + tools map[string]*toolReg + stages map[sdk.Stage]*lua.LFunction + mu sync.Mutex +} + +func newLuaPlugin(luaPath, name string) (*luaPlugin, error) { + L := lua.NewState() + + // 1) 加载嵌入式 sdk.lua(接口定义 + pure Lua mock 实现) + if err := L.DoString(luaSDK.SDKSource); err != nil { + L.Close() + return nil, fmt.Errorf("load sdk.lua: %w", err) + } + + sdkTbl := L.GetGlobal("sdk") + sdkTable, ok := sdkTbl.(*lua.LTable) + if !ok { + L.Close() + return nil, fmt.Errorf("sdk.lua must set global 'sdk' table") + } + + // 清除 DoString 留在栈上的返回值,栈顶归零 + L.SetTop(0) + + plg := &luaPlugin{ + name: name, + L: L, + tools: make(map[string]*toolReg), + stages: make(map[sdk.Stage]*lua.LFunction), + } + + // 2) 替换 !impl 函数为 Go stub(暂存 handler,等 Start 时注册到真实 SDK) + replaceSDKStubs(L, sdkTable, plg) + + // 3) 加载插件主脚本(此时 sdk.* 全局已就绪,带 stub 实现) + if err := L.DoFile(luaPath); err != nil { + L.Close() + return nil, fmt.Errorf("load %s: %w", luaPath, err) + } + + // 4) 如果脚本返回了 table,保存 + if L.GetTop() > 0 { + if tbl, ok := L.Get(-1).(*lua.LTable); ok { + plg.tbl = tbl + L.Pop(1) + } + } + + return plg, nil +} + +// replaceSDKStubs 替换 sdk 表中的 !impl 函数为 Go stub。 +// stub 暂存 handler,等 Start 时才注册到真实 SDK。 +func replaceSDKStubs(L *lua.LState, t *lua.LTable, plg *luaPlugin) { + t.RawSetString("log", L.NewFunction(func(L *lua.LState) int { + level := L.ToString(1) + msg := L.ToString(2) + fmt.Printf("[lua-plugin/%s] %s: %s\n", plg.name, level, msg) + return 0 + })) + + t.RawSetString("register_tool", L.NewFunction(func(L *lua.LState) int { + toolName := L.CheckString(1) + defTbl := L.CheckTable(2) + handler := L.CheckFunction(3) + + goDef := sdk.ToolDef{Name: toolName, Plugin: plg.name} + goDef.Description = defTbl.RawGetString("description").String() + if params := defTbl.RawGetString("parameters"); params != nil { + if pt, ok := params.(*lua.LTable); ok { + goDef.Parameters = make(map[string]interface{}) + pt.ForEach(func(k, v lua.LValue) { + goDef.Parameters[k.String()] = luaValueToGo(v) + }) + } + } + + plg.mu.Lock() + plg.tools[toolName] = &toolReg{def: goDef, handler: handler} + plg.mu.Unlock() + return 0 + })) + + t.RawSetString("register_stage", L.NewFunction(func(L *lua.LState) int { + stage := sdk.Stage(L.CheckString(1)) + handler := L.CheckFunction(2) + plg.mu.Lock() + plg.stages[stage] = handler + plg.mu.Unlock() + return 0 + })) + + t.RawSetString("register_api", L.NewFunction(func(L *lua.LState) int { + return 0 + })) + + t.RawSetString("get_setting", L.NewFunction(func(L *lua.LState) int { + L.Push(lua.LNil) + return 1 + })) + t.RawSetString("set_setting", L.NewFunction(func(L *lua.LState) int { + return 0 + })) + + t.RawSetString("inject_text", L.NewFunction(func(L *lua.LState) int { return 0 })) + t.RawSetString("inject_interrupt", L.NewFunction(func(L *lua.LState) int { return 0 })) + t.RawSetString("inject_text_no_memory", L.NewFunction(func(L *lua.LState) int { return 0 })) + + // http 子表 + if httpTable, ok := t.RawGetString("http").(*lua.LTable); ok { + httpTable.RawSetString("get", L.NewFunction(func(L *lua.LState) int { + url := L.CheckString(1) + resp, err := http.Get(url) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + result := L.NewTable() + result.RawSetString("status", lua.LNumber(resp.StatusCode)) + result.RawSetString("body", lua.LString(string(body))) + headers := L.NewTable() + for k, v := range resp.Header { + headers.RawSetString(k, lua.LString(strings.Join(v, ", "))) + } + result.RawSetString("headers", headers) + L.Push(result) + L.Push(lua.LNil) + return 2 + })) + httpTable.RawSetString("post", L.NewFunction(func(L *lua.LState) int { + url := L.CheckString(1) + body := L.CheckString(2) + contentType := L.OptString(3, "application/json") + resp, err := http.Post(url, contentType, strings.NewReader(body)) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + result := L.NewTable() + result.RawSetString("status", lua.LNumber(resp.StatusCode)) + result.RawSetString("body", lua.LString(string(respBody))) + L.Push(result) + L.Push(lua.LNil) + return 2 + })) + } +} + +// replaceSDKReal 用真实 SDK 实现替换 sdk 表。 +// 此时 plg.handlers/stages 已存有加载期间注册的 handler。 +func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginSDK) { + t.RawSetString("register_tool", L.NewFunction(func(L *lua.LState) int { + toolName := L.CheckString(1) + defTbl := L.CheckTable(2) + handler := L.CheckFunction(3) + + goDef := sdk.ToolDef{Name: toolName, Plugin: plg.name} + goDef.Description = defTbl.RawGetString("description").String() + if params := defTbl.RawGetString("parameters"); params != nil { + if pt, ok := params.(*lua.LTable); ok { + goDef.Parameters = make(map[string]interface{}) + pt.ForEach(func(k, v lua.LValue) { + goDef.Parameters[k.String()] = luaValueToGo(v) + }) + } + } + + h := makeToolHandler(plg, toolName, handler) + if err := s.RegisterTool(toolName, goDef, h); err != nil { + L.RaiseError("register_tool: %v", err) + } + return 0 + })) + + t.RawSetString("register_stage", L.NewFunction(func(L *lua.LState) int { + stage := sdk.Stage(L.CheckString(1)) + handler := L.CheckFunction(2) + + h := makeStageHandler(plg, stage, handler) + s.RegisterStage(stage, h) + return 0 + })) + + t.RawSetString("register_api", L.NewFunction(func(L *lua.LState) int { + apiName := L.CheckString(1) + s.RegisterPluginAPI(apiName) + return 0 + })) + + t.RawSetString("get_setting", L.NewFunction(func(L *lua.LState) int { + key := L.CheckString(1) + val, _ := s.Settings().Get(key) + L.Push(goValueToLua(L, val)) + return 1 + })) + t.RawSetString("set_setting", L.NewFunction(func(L *lua.LState) int { + key := L.CheckString(1) + val := luaValueToGo(L.CheckAny(2)) + s.Settings().Set(key, val) + return 0 + })) + + t.RawSetString("inject_text", L.NewFunction(func(L *lua.LState) int { + s.InjectText(L.CheckString(1), L.CheckString(2), L.CheckString(3)) + return 0 + })) + t.RawSetString("inject_interrupt", L.NewFunction(func(L *lua.LState) int { + s.InjectInterruptText(L.CheckString(1), L.CheckString(2), L.CheckString(3)) + return 0 + })) + t.RawSetString("inject_text_no_memory", L.NewFunction(func(L *lua.LState) int { + s.InjectTextNoMemory(L.CheckString(1), L.CheckString(2), L.CheckString(3)) + return 0 + })) +} + +func makeToolHandler(plg *luaPlugin, name string, fn *lua.LFunction) sdk.ToolHandler { + return func(args map[string]interface{}) (interface{}, error) { + plg.mu.Lock() + defer plg.mu.Unlock() + L := plg.L + L.Push(fn) + L.Push(goValueToLua(L, args)) + if err := L.PCall(1, 1, nil); err != nil { + return nil, fmt.Errorf("lua tool %s: %w", name, err) + } + result := L.Get(-1) + L.Pop(1) + return luaValueToGo(result), nil + } +} + +func makeStageHandler(plg *luaPlugin, stage sdk.Stage, fn *lua.LFunction) sdk.StageHandler { + return func(sc *sdk.StageContext) error { + plg.mu.Lock() + defer plg.mu.Unlock() + L := plg.L + L.Push(fn) + L.Push(goValueToLua(L, map[string]interface{}{ + "raw_message": sc.RawMessage, + "user_id": sc.UserID, + "phase": string(sc.Phase), + })) + if err := L.PCall(1, 0, nil); err != nil { + return fmt.Errorf("lua stage %s: %w", stage, err) + } + return nil + } +} + +func (p *luaPlugin) Name() string { return p.name } + +func (p *luaPlugin) Start(s *sdk.PluginSDK) error { + // 1) 用真实 SDK 实现替换 sdk 表函数 + sdkTbl := p.L.GetGlobal("sdk") + if sdkTable, ok := sdkTbl.(*lua.LTable); ok { + replaceSDKReal(p.L, sdkTable, p, s) + } + + // 2) 批量注册加载期已暂存的 tool handler + p.mu.Lock() + tools := make(map[string]*toolReg, len(p.tools)) + for k, v := range p.tools { + tools[k] = v + } + stages := make(map[sdk.Stage]*lua.LFunction, len(p.stages)) + for k, v := range p.stages { + stages[k] = v + } + p.mu.Unlock() + + for toolName, reg := range tools { + h := makeToolHandler(p, toolName, reg.handler) + s.RegisterTool(toolName, reg.def, h) + } + for stage, fn := range stages { + h := makeStageHandler(p, stage, fn) + s.RegisterStage(stage, h) + } + + // 3) 调用插件的 start(sdk) 回调 + if p.tbl != nil { + fn := p.tbl.RawGetString("start") + if fn != nil && fn != lua.LNil { + p.mu.Lock() + L := p.L + L.Push(fn) + L.Push(sdkTbl) + err := L.PCall(1, 0, nil) + p.mu.Unlock() + if err != nil { + return fmt.Errorf("lua start %s: %w", p.name, err) + } + } + } + + return nil +} + +func (p *luaPlugin) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.tbl != nil { + fn := p.tbl.RawGetString("stop") + if fn != nil && fn != lua.LNil { + L := p.L + L.Push(fn) + if err := L.PCall(0, 0, nil); err != nil { + p.L.Close() + return fmt.Errorf("lua stop %s: %w", p.name, err) + } + } + } + p.L.Close() + return nil +} diff --git a/internal/plugin/lua_plugin_test.go b/internal/plugin/lua_plugin_test.go new file mode 100644 index 0000000..952eac1 --- /dev/null +++ b/internal/plugin/lua_plugin_test.go @@ -0,0 +1,116 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTryLoadLua_Basic(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{ + "name": "testlua", + "name_zh": "测试Lua", + "name_en": "Test Lua", + "version": "1.0.0", + "entry": "main.lua" + }`), 0644) + + os.WriteFile(filepath.Join(dir, "main.lua"), []byte(` +local plugin = { + name = "testlua" +} + +function plugin.start(sdk) + sdk.log("info", "testlua started") + sdk.register_tool("testlua_hello", { + description = "Hello tool", + parameters = {type = "object", properties = {}} + }, function(args) + return {content = "hello from lua"} + end) +end + +function plugin.stop() + sdk.log("info", "testlua stopped") +end + +return plugin +`), 0644) + + plg, err := tryLoadLua(dir, "testlua", nil) + if err != nil { + t.Fatalf("tryLoadLua failed: %v", err) + } + if plg == nil { + t.Fatal("tryLoadLua returned nil") + } + if plg.Name() != "testlua" { + t.Fatalf("unexpected name: %s", plg.Name()) + } + t.Logf("plugin loaded: %s", plg.Name()) +} + +func TestTryLoadLua_NoFile(t *testing.T) { + dir := t.TempDir() + plg, err := tryLoadLua(dir, "nonexistent", nil) + if err != nil { + t.Fatalf("tryLoadLua on empty dir should not error: %v", err) + } + if plg != nil { + t.Fatal("expected nil for non-existent main.lua") + } +} + +func TestTryLoadLua_NoReturnTable(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"bad","entry":"main.lua"}`), 0644) + os.WriteFile(filepath.Join(dir, "main.lua"), []byte(` +-- just code, no return table +local x = 1 +sdk.log("info", "no return table test") +`), 0644) + + plg, err := tryLoadLua(dir, "bad", nil) + if err != nil { + t.Fatalf("tryLoadLua failed: %v", err) + } + if plg == nil { + t.Fatal("tryLoadLua returned nil") + } + t.Logf("loaded plugin without return table: %s", plg.Name()) +} + +func TestTryLoadLua_GlobalSDK(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"globalsdk","entry":"main.lua"}`), 0644) + os.WriteFile(filepath.Join(dir, "main.lua"), []byte(` +-- sdk is a global, should work without return table +sdk.log("info", "sdk is available as global") +sdk.register_tool("direct_tool", { + description = "registered directly in top-level code" +}, function(args) + return {result = "ok"} +end) +`), 0644) + + plg, err := tryLoadLua(dir, "globalsdk", nil) + if err != nil { + t.Fatalf("tryLoadLua failed: %v", err) + } + if plg == nil { + t.Fatal("tryLoadLua returned nil") + } + + lp := plg.(*luaPlugin) + lp.mu.Lock() + toolCount := len(lp.tools) + lp.mu.Unlock() + if toolCount != 1 { + t.Fatalf("expected 1 tool registration, got %d", toolCount) + } + t.Logf("tool registered during load phase: OK") +} diff --git a/internal/plugin/lua_util.go b/internal/plugin/lua_util.go new file mode 100644 index 0000000..3f5ea6b --- /dev/null +++ b/internal/plugin/lua_util.go @@ -0,0 +1,60 @@ +package plugin + +import lua "github.com/yuin/gopher-lua" + +func luaValueToGo(lv lua.LValue) interface{} { + switch v := lv.(type) { + case lua.LString: + return string(v) + case lua.LNumber: + return float64(v) + case lua.LBool: + return bool(v) + case *lua.LTable: + if v.MaxN() > 0 { + arr := make([]interface{}, 0, v.MaxN()) + v.ForEach(func(_, val lua.LValue) { + arr = append(arr, luaValueToGo(val)) + }) + return arr + } + m := make(map[string]interface{}) + v.ForEach(func(key, val lua.LValue) { + m[key.String()] = luaValueToGo(val) + }) + return m + default: + return nil + } +} + +func goValueToLua(L *lua.LState, val interface{}) lua.LValue { + switch v := val.(type) { + case string: + return lua.LString(v) + case float64: + return lua.LNumber(v) + case int: + return lua.LNumber(v) + case int64: + return lua.LNumber(v) + case bool: + return lua.LBool(v) + case nil: + return lua.LNil + case []interface{}: + tbl := L.NewTable() + for i, item := range v { + tbl.RawSetInt(i+1, goValueToLua(L, item)) + } + return tbl + case map[string]interface{}: + tbl := L.NewTable() + for k, item := range v { + tbl.RawSetString(k, goValueToLua(L, item)) + } + return tbl + default: + return lua.LNil + } +} diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index 9817d2a..14d2416 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -11,6 +11,8 @@ const PackageExt = ".hmap" // PluginManifest 每个插件目录中的 plugin.json 元数据。 type PluginManifest struct { Name string `json:"name"` + NameZh string `json:"name_zh,omitempty"` + NameEn string `json:"name_en,omitempty"` Version string `json:"version"` Description string `json:"description,omitempty"` Author string `json:"author,omitempty"` diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index bf8be52..727bfba 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -22,6 +22,28 @@ import ( type NativeFactory func(name string, config map[string]interface{}) (sdk.Plugin, error) +// PluginMeta 插件显示名称元数据。 +type PluginMeta struct { + NameZh string `json:"name_zh"` + NameEn string `json:"name_en"` +} + +var globalPluginMeta sync.Map // name -> PluginMeta + +// RegisterPluginMeta 供插件包在 init() 中调用,注册显示名称。 +func RegisterPluginMeta(name, nameZh, nameEn string) { + globalPluginMeta.Store(name, PluginMeta{NameZh: nameZh, NameEn: nameEn}) +} + +// GetPluginMeta 查询插件的显示名称。 +func GetPluginMeta(name string) (PluginMeta, bool) { + v, ok := globalPluginMeta.Load(name) + if !ok { + return PluginMeta{}, false + } + return v.(PluginMeta), true +} + // globalFactories 是插件通过 init() 自注册的全局工厂表。 // Registry.RegisterNative() 写入此表;Registry.Load() 从中查找。 var globalFactories sync.Map @@ -202,6 +224,13 @@ func (r *Registry) loadOne(plgDir, name string) bool { var plg sdk.Plugin + // 读取 plugin.json 以获取插件显示名称元数据(主要用于外部插件) + if mft := readManifest(plgDir); mft != nil { + if mft.NameZh != "" || mft.NameEn != "" { + RegisterPluginMeta(name, mft.NameZh, mft.NameEn) + } + } + if hasFactory { cfg := r.readConfig(plgDir) p, err := factory(name, cfg) @@ -277,16 +306,34 @@ func (r *Registry) Get(name string) sdk.Plugin { return r.plugins[name] } +func (r *Registry) PluginMetas() map[string]PluginMeta { + metas := make(map[string]PluginMeta) + globalPluginMeta.Range(func(key, val interface{}) bool { + metas[key.(string)] = val.(PluginMeta) + return true + }) + return metas +} + func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) { - // 优先尝试 .so(Go plugin),其次 .lua(Lua 脚本) - plg, err := tryLoadSO(plgDir, name, config) - if err != nil { - return nil, err + // 尝试顺序:.so (Go plugin on Linux) → .dll (Windows) → .lua (跨平台) + for _, try := range []struct { + name string + fn func(string, string, map[string]interface{}) (sdk.Plugin, error) + }{ + {"so", tryLoadSO}, + {"dll", tryLoadDLL}, + {"lua", tryLoadLua}, + } { + plg, err := try.fn(plgDir, name, config) + if err != nil { + return nil, err + } + if plg != nil { + return plg, nil + } } - if plg != nil { - return plg, nil - } - return tryLoadLua(plgDir, name, config) + return nil, nil } func (r *Registry) readConfig(plgDir string) map[string]interface{} { diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index fe45014..bac8f34 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -1,3 +1,5 @@ +//go:build linux + package agentcli import ( @@ -155,18 +157,13 @@ func (t *TerminalSession) IsExpired() bool { } type Plugin struct { - name string - mu sync.Mutex - wg sync.WaitGroup - stopCh chan struct{} - sessions map[string]*TerminalSession - nextID int -} - -func init() { - plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { - return New(name), nil - }) + name string + mu sync.Mutex + wg sync.WaitGroup + stopCh chan struct{} + sessions map[string]*TerminalSession + nextID int + defaultTimeout time.Duration } func New(name string) *Plugin { @@ -180,6 +177,22 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "default_timeout", Type: "string", DisplayName: "默认终端超时", + Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h(默认 5m)", + Default: "5m", + }) + if v, _ := s.Settings().Get("default_timeout"); v != nil { + if s, ok := v.(string); ok && s != "" { + if d, err := time.ParseDuration(s); err == nil { + p.defaultTimeout = d + } + } + } + if p.defaultTimeout <= 0 { + p.defaultTimeout = DefaultTimeout + } + s.RegisterTool("terminal_create", sdk.ToolDef{ Name: "terminal_create", Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。", @@ -339,7 +352,7 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in } timeoutStr, _ := args["timeout"].(string) - timeout := DefaultTimeout + timeout := p.defaultTimeout if timeoutStr != "" { if d, err := time.ParseDuration(timeoutStr); err == nil { timeout = d @@ -795,6 +808,13 @@ func isTimeoutError(err error) bool { return strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "would block") } +func init() { + plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) + plugin.RegisterPluginMeta("agentcli", "终端交互", "Agent CLI") +} + func sanitizePreview(s string) string { var buf bytes.Buffer for _, r := range s { diff --git a/internal/plugins/agentcli/plugin_stub.go b/internal/plugins/agentcli/plugin_stub.go new file mode 100644 index 0000000..57c2649 --- /dev/null +++ b/internal/plugins/agentcli/plugin_stub.go @@ -0,0 +1,21 @@ +//go:build !linux + +package agentcli + +import ( + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func init() { + plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &stubPlugin{}, nil + }) + plugin.RegisterPluginMeta("agentcli", "终端交互", "Agent CLI") +} + +type stubPlugin struct{} + +func (p *stubPlugin) Name() string { return "agentcli" } +func (p *stubPlugin) Start(sdk *sdk.PluginSDK) error { return nil } +func (p *stubPlugin) Stop() error { return nil } diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 249585b..f7186bc 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -38,6 +38,7 @@ func Configure(pr *plugin.Registry, cr *internalConfig.ConfigRegistry, sp agentC } func init() { + plugin.RegisterPluginMeta("cli", "CLI", "CLI") plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { sock := DefaultSocket if sock == "" { @@ -69,6 +70,20 @@ func New(name, socketPath string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "api_key", Type: "password", DisplayName: "CLI API 密钥", + Description: "CLI 客户端连接时需提供的认证密钥(留空则使用 WebUI 密钥)", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "socket_path", Type: "string", DisplayName: "Socket 管道路径", + Description: "CLI Unix 域套接字监听路径(留空则使用默认路径)", + }) + if v, _ := s.Settings().Get("socket_path"); v != nil { + if s, ok := v.(string); ok && s != "" { + p.socket = s + } + } + dir := filepath.Dir(p.socket) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create socket dir: %w", err) @@ -109,7 +124,7 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { scanner := bufio.NewScanner(conn) - apiKey := p.webuiAPIKey() + apiKey := p.cliAPIKey(s) if apiKey != "" { if !scanner.Scan() { return @@ -156,6 +171,17 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { } } +func (p *Plugin) cliAPIKey(s *sdk.PluginSDK) string { + if s != nil { + if v, _ := s.Settings().Get("api_key"); v != nil { + if k, ok := v.(string); ok && k != "" { + return k + } + } + } + return p.webuiAPIKey() +} + func (p *Plugin) webuiAPIKey() string { if cfgReg == nil { return "" diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index d2dbd01..a339d38 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -6,6 +6,7 @@ import ( "fmt" "os/exec" "strings" + "sync" "time" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -41,13 +42,32 @@ func shellUnquote(s string) []string { } func init() { + plugin.RegisterPluginMeta("cmd", "命令执行", "Command") plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) { return New(name), nil }) } +type cmdRecord struct { + Timestamp time.Time `json:"timestamp"` + Command string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Workdir string `json:"workdir"` + Timeout string `json:"timeout"` + Duration string `json:"duration"` + Status string `json:"status"` +} + +const maxHistory = 100 + type Plugin struct { - name string + name string + defaultTimeout string + maxOutput int + mu sync.Mutex + history []cmdRecord } func New(name string) *Plugin { @@ -57,6 +77,30 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "default_timeout", Type: "string", DisplayName: "默认命令超时", + Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)", + Default: "30s", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "max_output_bytes", Type: "int", DisplayName: "最大输出字节数", + Description: "命令输出的最大字节数,超出部分将被截断(默认 32000)", + Default: "32000", + }) + p.maxOutput = 32000 + if v, _ := s.Settings().Get("max_output_bytes"); v != nil { + if s, ok := v.(string); ok && s != "" { + if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 { + } + } + } + p.defaultTimeout = "30s" + if v, _ := s.Settings().Get("default_timeout"); v != nil { + if s, ok := v.(string); ok && s != "" { + p.defaultTimeout = s + } + } + s.RegisterTool("cmd_run", sdk.ToolDef{ Name: "cmd_run", Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。", @@ -86,7 +130,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { timeoutStr, _ := args["timeout"].(string) if timeoutStr == "" { - timeoutStr = "30s" + timeoutStr = p.defaultTimeout } timeout, err := time.ParseDuration(timeoutStr) if err != nil { @@ -104,6 +148,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } } + tStart := time.Now() ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -120,22 +165,40 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { cmd.Stdout = &stdout cmd.Stderr = &stderr + rec := cmdRecord{Timestamp: tStart, Command: command, Workdir: workdir, Timeout: timeoutStr} + exitCode := -1 + if err := cmd.Run(); err != nil { if ctx.Err() != nil { + rec.Status = "timeout" + rec.Stdout = p.truncateOutput(stdout.String()) + rec.Stderr = p.truncateOutput(stderr.String()) + rec.Duration = time.Since(tStart).Round(time.Millisecond).String() + p.recordCmd(rec) return map[string]interface{}{ "status": "timeout", - "stdout": truncateOutput(stdout.String()), - "stderr": truncateOutput(stderr.String()), + "stdout": rec.Stdout, + "stderr": rec.Stderr, "error": fmt.Sprintf("命令执行超时(%s)", timeoutStr), }, nil } } + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + rec.Status = "ok" + rec.Stdout = p.truncateOutput(stdout.String()) + rec.Stderr = p.truncateOutput(stderr.String()) + rec.ExitCode = exitCode + rec.Duration = time.Since(tStart).Round(time.Millisecond).String() + p.recordCmd(rec) return map[string]interface{}{ "status": "ok", - "stdout": truncateOutput(stdout.String()), - "stderr": truncateOutput(stderr.String()), - "exit_code": cmd.ProcessState.ExitCode(), + "stdout": rec.Stdout, + "stderr": rec.Stderr, + "exit_code": exitCode, "command": command, }, nil }) @@ -147,8 +210,20 @@ func (p *Plugin) Stop() error { return nil } -func truncateOutput(s string) string { - const maxLen = 32000 +func (p *Plugin) recordCmd(r cmdRecord) { + p.mu.Lock() + defer p.mu.Unlock() + p.history = append(p.history, r) + if len(p.history) > maxHistory { + p.history = p.history[len(p.history)-maxHistory:] + } +} + +func (p *Plugin) truncateOutput(s string) string { + maxLen := p.maxOutput + if maxLen <= 0 { + maxLen = 32000 + } if len(s) > maxLen { return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s)) } diff --git a/internal/plugins/files/plugin.go b/internal/plugins/files/plugin.go index 68cb80c..d98338c 100644 --- a/internal/plugins/files/plugin.go +++ b/internal/plugins/files/plugin.go @@ -14,6 +14,7 @@ import ( ) func init() { + plugin.RegisterPluginMeta("files", "文件系统", "Files") plugin.RegisterFactory("files", func(name string, config map[string]interface{}) (sdk.Plugin, error) { return New(name), nil }) diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index 57c0355..c067d6d 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -63,6 +63,7 @@ func Configure(sh *agentCore.StageHost, iom *agentIO.IOManager, pr *plugin.Regis } func init() { + plugin.RegisterPluginMeta("healthcheck", "健康检查", "Health Check") plugin.RegisterFactory("healthcheck", func(name string, config map[string]interface{}) (sdk.Plugin, error) { if hcStageHost == nil { return nil, nil @@ -81,6 +82,12 @@ type Plugin struct { stopCh chan struct{} stopOnce sync.Once perfData PerfData + + autoInterval time.Duration + llmTimeout time.Duration + llmMaxTurns int + llmMaxTokens int + perfHistory int } type PerfData struct { @@ -106,6 +113,74 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.autoInterval = 30 * time.Minute + p.llmTimeout = 120 * time.Second + p.llmMaxTurns = 20 + p.llmMaxTokens = 4096 + p.perfHistory = 100 + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "auto_interval", Type: "string", DisplayName: "自动检查间隔", + Description: "自动健康检查的执行间隔,例如 30m, 1h, 10m(设为 0 禁用)", + Default: "30m", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "llm_timeout", Type: "string", DisplayName: "LLM 检查超时", + Description: "LLM 驱动检查的超时时间,例如 120s, 3m, 5m(默认 120s)", + Default: "120s", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "llm_max_turns", Type: "int", DisplayName: "LLM 最大对话轮数", + Description: "LLM 工具发现的最大对话轮数(默认 20)", + Default: "20", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "llm_max_tokens", Type: "int", DisplayName: "LLM 最大 Token", + Description: "LLM 调用时的最大 Token 数(默认 4096)", + Default: "4096", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "perf_history", Type: "int", DisplayName: "性能历史保留数", + Description: "保留的历史检查记录条数(默认 100)", + Default: "100", + }) + + if v, _ := s.Settings().Get("auto_interval"); v != nil { + if s, ok := v.(string); ok && s != "" { + if d, err := time.ParseDuration(s); err == nil && d > 0 { + p.autoInterval = d + } + } + } + if v, _ := s.Settings().Get("llm_timeout"); v != nil { + if s, ok := v.(string); ok && s != "" { + if d, err := time.ParseDuration(s); err == nil && d > 0 { + p.llmTimeout = d + } + } + } + if v, _ := s.Settings().Get("llm_max_turns"); v != nil { + if s, ok := v.(string); ok && s != "" { + if n, err := fmt.Sscanf(s, "%d", &p.llmMaxTurns); err != nil || n < 1 { + p.llmMaxTurns = 20 + } + } + } + if v, _ := s.Settings().Get("llm_max_tokens"); v != nil { + if s, ok := v.(string); ok && s != "" { + if n, err := fmt.Sscanf(s, "%d", &p.llmMaxTokens); err != nil || n < 1 { + p.llmMaxTokens = 4096 + } + } + } + if v, _ := s.Settings().Get("perf_history"); v != nil { + if s, ok := v.(string); ok && s != "" { + if n, err := fmt.Sscanf(s, "%d", &p.perfHistory); err != nil || n < 1 { + p.perfHistory = 100 + } + } + } + p.selfToolNames["healthcheck"] = true s.RegisterTool("healthcheck", sdk.ToolDef{ Name: "healthcheck", @@ -220,7 +295,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, nil }) - p.startAutoCheck(s, 30*time.Minute) + if p.autoInterval > 0 { + p.startAutoCheck(s, p.autoInterval) + } log.Printf("[healthcheck] ready (stageHost=%v iom=%v reg=%v mem=%v ks=%v ds=%v pm=%v sp=%v)", hcStageHost != nil, hcIOMgr != nil, hcPluginReg != nil, @@ -278,8 +355,8 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) { p.mu.Lock() p.perfData.LastCheck = pt.Time p.perfData.Checks = append(p.perfData.Checks, pt) - if len(p.perfData.Checks) > 100 { - p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-100:] + if len(p.perfData.Checks) > p.perfHistory { + p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-p.perfHistory:] } p.mu.Unlock() @@ -511,7 +588,7 @@ func (p *Plugin) testLLMDriven() checkResult { } start := time.Now() - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), p.llmTimeout) defer cancel() // 收集所有工具定义(排除健康检查自身的工具以避免循环测试) @@ -537,10 +614,10 @@ func (p *Plugin) testLLMDriven() checkResult { turnCount := 0 toolCallCount := 0 - for turn := 0; turn < 20; turn++ { + for turn := 0; turn < p.llmMaxTurns; turn++ { resp, err := provider.Chat(ctx, &agentAPI.CompletionRequest{ Messages: msgs, - MaxTokens: 4096, + MaxTokens: p.llmMaxTokens, Tools: tools, ToolChoice: "auto", }) diff --git a/internal/plugins/mcp/plugin.go b/internal/plugins/mcp/plugin.go index 2ecbf18..a382779 100644 --- a/internal/plugins/mcp/plugin.go +++ b/internal/plugins/mcp/plugin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "strings" "sync" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -20,6 +21,7 @@ type serverConfig struct { } func init() { + plugin.RegisterPluginMeta("mcp", "MCP 服务器", "MCP") plugin.RegisterFactory("mcp", func(name string, config map[string]interface{}) (sdk.Plugin, error) { return New(name), nil }) @@ -39,15 +41,6 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { - 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) @@ -89,7 +82,49 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } func (p *Plugin) loadConfig(s *sdk.PluginSDK) ([]serverConfig, error) { - // 优先从 skill.json(config map)读取 + // 优先从独立服务器配置键读取(servers..) + keys, _ := s.Settings().List("servers.") + if len(keys) > 0 { + serverNames := make(map[string]bool) + for _, k := range keys { + parts := strings.SplitN(k, ".", 3) + if len(parts) >= 2 { + serverNames[parts[1]] = true + } + } + var cfgs []serverConfig + for name := range serverNames { + cfg := serverConfig{Name: name} + if v, _ := s.Settings().Get("servers." + name + ".command"); v != nil { + if s, ok := v.(string); ok { + cfg.Command = s + } + } + if v, _ := s.Settings().Get("servers." + name + ".url"); v != nil { + if s, ok := v.(string); ok { + cfg.URL = s + } + } + if v, _ := s.Settings().Get("servers." + name + ".args"); v != nil { + if s, ok := v.(string); ok && s != "" { + json.Unmarshal([]byte(s), &cfg.Args) + } + } + if v, _ := s.Settings().Get("servers." + name + ".env"); v != nil { + if s, ok := v.(string); ok && s != "" { + json.Unmarshal([]byte(s), &cfg.Env) + } + } + if cfg.Command != "" || cfg.URL != "" { + cfgs = append(cfgs, cfg) + } + } + if len(cfgs) > 0 { + return cfgs, nil + } + } + + // 回退:从旧版 JSON blob 读取 raw, err := s.Settings().Get("servers") if err == nil { switch v := raw.(type) { @@ -107,8 +142,6 @@ func (p *Plugin) loadConfig(s *sdk.PluginSDK) ([]serverConfig, error) { } } - // 备用:从 JSON 文件读取 - // 没有配置时不报错,只返回空 return nil, nil } diff --git a/internal/plugins/openclaw/plugin.go b/internal/plugins/openclaw/plugin.go index 8d28c1e..a6831ac 100644 --- a/internal/plugins/openclaw/plugin.go +++ b/internal/plugins/openclaw/plugin.go @@ -27,6 +27,7 @@ var SkillsDir string var SimulatorDir string func init() { + plugin.RegisterPluginMeta("openclaw", "开放式交互", "OpenClaw") plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) { dir := SkillsDir if dir == "" { @@ -69,6 +70,25 @@ func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "skills_dir", Type: "string", DisplayName: "Skill 加载目录", + Description: "OpenClaw 技能加载目录路径(留空则使用默认路径)", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录", + Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)", + }) + if v, _ := s.Settings().Get("skills_dir"); v != nil { + if s, ok := v.(string); ok && s != "" { + p.skillsDir = s + } + } + if v, _ := s.Settings().Get("simulator_dir"); v != nil { + if s, ok := v.(string); ok && s != "" { + p.simulatorDir = s + } + } + // Launch OC plugin manager first (handles OC-format plugin installation and lifecycle) os.MkdirAll(p.skillsDir, 0755) if err := p.launchManager(s); err != nil { diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index f87ce7a..8174799 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -41,6 +41,7 @@ var ( ) func init() { + plugin.RegisterPluginMeta("pluginmgr", "插件管理", "Plugin Manager") plugin.RegisterFactory("pluginmgr", func(name string, config map[string]interface{}) (sdk.Plugin, error) { return New(name), nil }) @@ -504,7 +505,7 @@ func validatePackage(data []byte) (*pluginPackage, error) { 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} + valid := map[string]bool{"plugin.so": true, "plugin.dll": true, "main.lua": true, "SKILL.md": true} if !valid[pkg.Entry] { return nil, fmt.Errorf("unsupported entry: %q", pkg.Entry) } diff --git a/internal/plugins/timer/plugin.go b/internal/plugins/timer/plugin.go index 0b56faf..4b0b1c1 100644 --- a/internal/plugins/timer/plugin.go +++ b/internal/plugins/timer/plugin.go @@ -11,6 +11,7 @@ import ( ) func init() { + plugin.RegisterPluginMeta("timer", "定时任务", "Timer") plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) { return New(name), nil }) @@ -21,6 +22,7 @@ type Plugin struct { mu sync.Mutex wg sync.WaitGroup stopCh chan struct{} + maxDur time.Duration } type timerTask struct { @@ -38,6 +40,20 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.maxDur = 24 * time.Hour + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "max_duration", Type: "string", DisplayName: "最大定时时长", + Description: "允许设置的最大定时时长,例如 24h, 7d, 1h(默认 24h)", + Default: "24h", + }) + if v, _ := s.Settings().Get("max_duration"); v != nil { + if s, ok := v.(string); ok && s != "" { + if d, err := time.ParseDuration(s); err == nil && d > 0 { + p.maxDur = d + } + } + } + s.RegisterTool("timer_set", sdk.ToolDef{ Name: "timer_set", Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。", @@ -69,6 +85,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { if err != nil { return map[string]interface{}{"error": fmt.Sprintf("invalid duration %q: %v", durStr, err)}, nil } + if dur > p.maxDur { + return map[string]interface{}{"error": fmt.Sprintf("duration %v exceeds max %v", dur, p.maxDur)}, nil + } p.mu.Lock() p.wg.Add(1) diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index b10c457..5764052 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -1,26 +1,33 @@ - + HomeAgent Dashboard + + + - +
-
-
+
diff --git a/internal/plugins/webui/dashboard2.html b/internal/plugins/webui/dashboard2.html new file mode 100644 index 0000000..a294096 --- /dev/null +++ b/internal/plugins/webui/dashboard2.html @@ -0,0 +1 @@ +function renderOneSettings(){let prefix=state.selectedSection+'.';let allKeys=Object.keys(state.settings||{});let filtered=allKeys.filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let hideTopLlms=['core.llm.base_url','core.llm.model','core.llm.api_key','core.llm.adapter','core.llm.adapter_path','core.llm.thinking_enabled'];let sourceKeys=filtered.filter(k=>k.startsWith('core.llm.sources.'));let sourceMap={};sourceKeys.forEach(k=>{let parts=k.split('.');let srcName=parts[3];if(!sourceMap[srcName])sourceMap[srcName]={};sourceMap[srcName][k]=true});let mcpServerKeys=filtered.filter(k=>k.startsWith('plugin.mcp.servers.')&&k.split('.').length>=5);let mcpServerMap={};mcpServerKeys.forEach(k=>{let parts=k.split('.');let srvName=parts[3];if(!mcpServerMap[srvName])mcpServerMap[srvName]={};mcpServerMap[srvName][k]=true});let regularKeys=filtered.filter(k=>!k.startsWith('core.llm.sources.')&&hideTopLlms.indexOf(k)===-1&&!k.startsWith('plugin.mcp.servers.')&&k!=='plugin.mcp.servers');let html='
';if(regularKeys.length===0&&Object.keys(sourceMap).length===0&&Object.keys(mcpServerMap).length===0&&state.selectedSection!=='plugin.mcp'){html+='

'+escHtml(state.selectedSection)+'

暂无设置?/p>

'}else{regularKeys.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=''}else if(typ==='password'){inp=''}else if(typ==='text'){inp=''}else if(typ==='select'&&opts.length>0){let sel='';inp=sel}else if(typ==='int'){inp=''}else{inp=''}html+='
'+escHtml(k)+'
'+inp;if(desc){html+='

'+escHtml(desc)+'

'}html+='
'})}if(state.selectedSection==='core'){let srcNames=Object.keys(sourceMap).sort();srcNames.forEach(sn=>{let baseKey='core.llm.sources.'+sn;let srcHtml='

🌐 '+escHtml(sn)+'

';let fields=[{key:'adapter',label:'适配?},{key:'base_url',label:'API 地址'},{key:'model',label:'模型'},{key:'api_key',label:'API 密钥',typ:'password'},{key:'thinking_enabled',label:'深度思?,typ:'bool'}];fields.forEach(f=>{let fk=baseKey+'.'+f.key;let fv=state.settings[fk];let fsv=typeof fv==='object'?JSON.stringify(fv):String(fv||'');let fm=state.meta?.[fk];let ftyp=f.typ||fm?.type||'string';let finpId='inp-'+(baseKey+'.'+f.key).replace(/\./g,'_');let finp='';if(ftyp==='bool'){let chk=fsv==='true'?'checked':'';finp=''}else if(ftyp==='password'){finp=''}else if(f.key==='adapter'&&window._adapters&&window._adapters.length){let sel='';finp=sel}else{finp=''}srcHtml+=finp});srcHtml+='
';html+=srcHtml});html+='
'};if(state.selectedSection==='plugin.mcp'){Object.keys(mcpServerMap).sort().forEach(sn=>{let baseKey='plugin.mcp.servers.'+sn;let cmd=state.settings[baseKey+'.command'],url=state.settings[baseKey+'.url'];if((!cmd||cmd==='')&&(!url||url===''))return;let html2='

⚙ '+escHtml(sn)+'

';let fields=[{key:'command',label:'命令'},{key:'url',label:'URL'},{key:'args',label:'参数(JSON)'},{key:'env',label:'环境变量(JSON)'}];fields.forEach(f=>{let fk=baseKey+'.'+f.key;let fv=state.settings[fk];let fsv=typeof fv==='object'?JSON.stringify(fv):String(fv||'');let finpId='inp-'+fk.replace(/\./g,'_');let finp='';if(f.key==='args'||f.key==='env'){finp=''}else{finp=''}html2+=finp});html2+='
';html+=html2});html+='
'}}html+='
' diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 445b706..b37c358 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -15,6 +15,7 @@ import ( "sync" "time" + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" @@ -61,16 +62,55 @@ type Handler struct { pluginReg *plugin.Registry eventBus *events.Bus statusProvider agentCore.StatusProvider + providerMgr *agentAPI.ProviderManager + baseAPIKey string sessionMu sync.Mutex sessions map[string]time.Time + + chatMu sync.Mutex + chatHistory []ChatMsg + cmdMu sync.Mutex + cmdHistory []CmdExec + termMu sync.Mutex + termStates map[string]*termState } -func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider) *Handler { +type ChatMsg struct { + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Time string `json:"time"` +} + +type CmdExec struct { + Command string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Status string `json:"status"` + Time string `json:"time"` +} + +type termState struct { + ID string `json:"id"` + Command string `json:"command"` + Running bool `json:"running"` + Output string `json:"output"` + CreatedAt string `json:"created_at"` + Uptime string `json:"uptime"` + created time.Time +} + +const maxChatHistory = 200 +const maxCmdHistory = 100 +const maxTerminals = 50 + +func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider, pm *agentAPI.ProviderManager, baseKey string) *Handler { var idx *memory.Indexer if mem != nil { idx = memory.NewIndexer(mem) } - return &Handler{ + h := &Handler{ supervisor: sup, memory: mem, indexer: idx, @@ -86,8 +126,83 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, pluginReg: pr, eventBus: evBus, statusProvider: sp, + providerMgr: pm, + baseAPIKey: baseKey, sessions: make(map[string]time.Time), + termStates: make(map[string]*termState), } + if evBus != nil { + go h.trackToolEvents() + } + return h +} + +func (h *Handler) trackToolEvents() { + h.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) { + h.handleToolEvent(ev) + }) +} + +func (h *Handler) handleToolEvent(ev *events.Event) { + payload := ev.Payload + tool, _ := payload["tool"].(string) + args, _ := payload["args"].(map[string]interface{}) + status, _ := payload["status"].(string) + ts := time.Now() + + switch tool { + case "cmd_run": + exec := CmdExec{ + Command: getStr(args, "command"), + Status: status, + Time: ts.Format(time.RFC3339), + } + h.cmdMu.Lock() + h.cmdHistory = append(h.cmdHistory, exec) + if len(h.cmdHistory) > maxCmdHistory { + h.cmdHistory = h.cmdHistory[len(h.cmdHistory)-maxCmdHistory:] + } + h.cmdMu.Unlock() + + case "terminal_create": + id := getStr(args, "id") + cmd := getStr(args, "command") + now := time.Now() + term := &termState{ + ID: id, + Command: cmd, + Running: true, + CreatedAt: now.Format(time.RFC3339), + created: now, + } + h.termMu.Lock() + h.termStates[id] = term + if len(h.termStates) > maxTerminals { + for k := range h.termStates { + delete(h.termStates, k) + break + } + } + h.termMu.Unlock() + + case "terminal_close": + id := getStr(args, "id") + if id != "" { + h.termMu.Lock() + if t, ok := h.termStates[id]; ok { + t.Running = false + } + h.termMu.Unlock() + } + } +} + +func getStr(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + v, _ := m[key].(string) + return v } func (h *Handler) getWebUIConfig() (apiKey, username, password string, ttl time.Duration) { @@ -204,6 +319,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/skills", h.requireAPI(h.handleSkills)) mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory)) mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory)) + mux.HandleFunc("/api/v1/memory/graph", h.requireAPI(h.handleMemoryGraph)) mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext)) mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools)) mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory)) @@ -218,7 +334,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/tracker", h.requireAPI(h.handleTracker)) mux.HandleFunc("/api/v1/tracker/", h.requireAPI(h.handleTracker)) mux.HandleFunc("/api/v1/chat", h.requireAPI(h.handleChat)) + mux.HandleFunc("/api/v1/chat/history", h.requireAPI(h.handleChatHistory)) mux.HandleFunc("/api/v1/chat/events", h.requireAPI(h.handleChatEvents)) + mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals)) + mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory)) mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel)) mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins)) mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) @@ -528,6 +647,23 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.memory == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"}) + return + } + data, err := h.memory.GraphData() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data}) +} + func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -652,7 +788,7 @@ func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - if err := h.lua.ReloadAll(); err != nil { + if err := h.lua.LoadAdapter(path); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -687,7 +823,7 @@ func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"}) return } - h.lua.ReloadAll() + h.lua.RemoveAdapter(name) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -705,6 +841,42 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) addChatMsg(msg ChatMsg) { + h.chatMu.Lock() + defer h.chatMu.Unlock() + h.chatHistory = append(h.chatHistory, msg) + if len(h.chatHistory) > maxChatHistory { + h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:] + } +} + +func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) { + h.chatMu.Lock() + result := make([]ChatMsg, len(h.chatHistory)) + copy(result, h.chatHistory) + h.chatMu.Unlock() + writeJSON(w, http.StatusOK, map[string]interface{}{"messages": result}) +} + +func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) { + h.termMu.Lock() + terms := make([]*termState, 0, len(h.termStates)) + for _, ts := range h.termStates { + ts.Uptime = time.Since(ts.created).Round(time.Second).String() + terms = append(terms, ts) + } + h.termMu.Unlock() + writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms}) +} + +func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) { + h.cmdMu.Lock() + result := make([]CmdExec, len(h.cmdHistory)) + copy(result, h.cmdHistory) + h.cmdMu.Unlock() + writeJSON(w, http.StatusOK, map[string]interface{}{"history": result}) +} + func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -722,15 +894,22 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { return } + h.addChatMsg(ChatMsg{Role: "user", Content: body.Message, Time: time.Now().Format(time.RFC3339)}) resp := h.iom.InjectTextSync("cli", body.Message) if resp == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) return } content, _ := resp.Payload["content"].(string) - writeJSON(w, http.StatusOK, map[string]interface{}{ + reasoning, _ := resp.Payload["reasoning_content"].(string) + result := map[string]interface{}{ "response": content, - }) + } + if reasoning != "" { + result["reasoning_content"] = reasoning + } + h.addChatMsg(ChatMsg{Role: "assistant", Content: content, ReasoningContent: reasoning, Time: time.Now().Format(time.RFC3339)}) + writeJSON(w, http.StatusOK, result) } func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { @@ -771,15 +950,17 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { } }() - unsub := h.eventBus.Subscribe(events.EventAll, func(evt *events.Event) { - data, _ := json.Marshal(evt) - select { - case writeCh <- fmt.Sprintf("event: %s\ndata: %s", evt.Type, string(data)): - default: - } - }) - defer unsub() - + subTypes := []string{"agent_output", "reasoning", "agent_error"} + for _, t := range subTypes { + t2 := t + _ = h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) { + data, _ := json.Marshal(evt) + select { + case writeCh <- fmt.Sprintf("event: %s\ndata: %s", evt.Type, string(data)): + default: + } + }) + } for { select { case <-done: @@ -844,10 +1025,30 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { defs := h.cfgReg.ListDefs(prefix) for _, d := range defs { meta[d.Key] = d + // 有 def 但 DB 中尚无值的 key,用 default 填充以便在 WebUI 中显示和编辑 + if _, exists := values[d.Key]; !exists { + values[d.Key] = d.Default + } + } + // 无前缀时同时加载所有插件配置 + if prefix == "" && h.pluginReg != nil { + for _, p := range h.pluginReg.List() { + ps := h.cfgReg.PluginConfig(p) + pkeys, _ := ps.List("") + for _, k := range pkeys { + v, _ := ps.Get(k) + fullKey := "plugin." + p + "." + k + values[fullKey] = v + if def := h.cfgReg.GetDef(fullKey); def != nil { + meta[fullKey] = def + } + } + } } } plugins := []string{"core"} + pm := h.pluginReg.PluginMetas() if h.pluginReg != nil { for _, p := range h.pluginReg.List() { plugins = append(plugins, "plugin."+p) @@ -855,9 +1056,10 @@ 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, + "settings": values, + "meta": meta, + "plugins": plugins, + "plugin_meta": pm, }) case http.MethodPut: var body struct { @@ -883,12 +1085,37 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { return } } + if strings.HasPrefix(body.Key, "core.llm.") && h.providerMgr != nil && h.lua != nil { + h.reloadLLMProviders() + } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } +func (h *Handler) reloadLLMProviders() { + cfg := h.cfgReg.ToConfig() + h.providerMgr.Reset() + for _, src := range cfg.LLM.Sources { + key := src.APIKey + if key == "" { + key = h.baseAPIKey + } + provider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: src.Model, + BaseURL: src.BaseURL, + APIKey: key, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + }, h.lua, src.Adapter) + h.providerMgr.Register(src.Name, provider) + } + if cfg.LLM.Provider != "" { + _ = h.providerMgr.SetDefault(cfg.LLM.Provider) + } +} + func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -1169,6 +1396,9 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") w.Write([]byte(dashboardHTML)) return } diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 1be864e..221fa98 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -7,6 +7,7 @@ import ( "log" "net/http" + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" @@ -39,6 +40,8 @@ var ( webuiPR *plugin.Registry webuiEvBus *events.Bus webuiStatusProvider agentCore.StatusProvider + webuiProviderMgr *agentAPI.ProviderManager + webuiBaseAPIKey string ) // Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。 @@ -47,16 +50,19 @@ func Configure(addr string, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, - sp agentCore.StatusProvider, + sp agentCore.StatusProvider, pm *agentAPI.ProviderManager, baseKey string, ) { webuiAddr = addr webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus webuiStatusProvider = sp + webuiProviderMgr = pm + webuiBaseAPIKey = baseKey } func init() { + plugin.RegisterPluginMeta("webui", "Web 控制台", "WebUI") plugin.RegisterFactory("webui", func(name string, config map[string]interface{}) (sdk.Plugin, error) { if webuiSup == nil { return nil, nil // 未 Configure 则跳过(不给日志警告) @@ -69,6 +75,7 @@ func init() { webuiSup, webuiMem, webuiSK, webuiLua, webuiCfg, webuiIOM, webuiTM, webuiKS, webuiTR, webuiCR, webuiPR, webuiEvBus, webuiStatusProvider, + webuiProviderMgr, webuiBaseAPIKey, ), nil }) } @@ -93,6 +100,8 @@ type Plugin struct { pr *plugin.Registry evBus *events.Bus statusProvider agentCore.StatusProvider + providerMgr *agentAPI.ProviderManager + baseAPIKey string } func New(name, addr string, @@ -100,7 +109,7 @@ func New(name, addr string, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, - sp agentCore.StatusProvider, + sp agentCore.StatusProvider, pm *agentAPI.ProviderManager, baseKey string, ) *Plugin { return &Plugin{ name: name, @@ -108,7 +117,7 @@ func New(name, addr string, mux: http.NewServeMux(), sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg, iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus, - statusProvider: sp, + statusProvider: sp, providerMgr: pm, baseAPIKey: baseKey, } } @@ -151,7 +160,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"}) p.ensureAuthBootstrap(s) - h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider) + h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider, p.providerMgr, p.baseAPIKey) p.handler = h h.RegisterRoutes(p.mux) diff --git a/pkg/types/types.go b/pkg/types/types.go index 7119b05..73ff7de 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -122,6 +122,7 @@ type ImageProcessingConfig struct { FallbackModel string `json:"fallback_model" yaml:"fallback_model"` DescribePrompt string `json:"describe_prompt" yaml:"describe_prompt"` OCREnabled bool `json:"ocr_enabled" yaml:"ocr_enabled"` + OCRPrompt string `json:"ocr_prompt" yaml:"ocr_prompt"` } type AudioProcessingConfig struct { diff --git a/plan.md b/plan.md deleted file mode 100644 index d8adef3..0000000 --- a/plan.md +++ /dev/null @@ -1,123 +0,0 @@ -# HomeAgent 修复计划 - -> 基于 `修复计划.md`,按优先级逐项推进。 - ---- - -## P0 — 功能正确性(必须修) - -### P0-1 HTTP 401/403 检测靠字符串搜索 -- **文件**: `internal/agent/core/agent.go:708-711` -- **问题**: `strings.Contains(errStr, "401")` 不可靠,Lua Adapter 返回格式不固定 -- **修复**: `ProviderManager` 增加 `ReportStatus(name, statusCode)`,在 `LuaAdaptedProvider.Chat()` 中根据 `resp.StatusCode` 精确判断 - -### P0-2 deploy/homeagent.service 传递 -config 参数使 homed 启动崩溃 -- **文件**: `deploy/homeagent.service:9` -- **问题**: `ExecStart` 含 `-config`,但 `cmd/homed/main.go` 未定义此 flag -- **修复**: 删除 `-config` 参数 - ---- - -## P2 — 中风险 - -### P2-1 context.go 每次 Append/Prune 全量写盘 -- **文件**: `internal/agent/core/context.go:88-101` -- **问题**: 30 条事件 JSON 全量写入文件每次操作,高频 I/O 瓶颈 -- **修复**: 增加 debounce 定时写入(每 5s flush) - -### P2-2 output_set_channel 枚举硬编码 -- **文件**: `internal/agent/core/agent.go:1927-1928` -- **问题**: channel enum 硬编码为 `{"voice", "email", "screen", "http"}`,与 Device 动态注册脱节 -- **修复**: 从 `a.io.ListChannels()` 动态生成 enum - -### P2-3 describe_image/transcribe_audio/ocr_image 三重复代码 -- **文件**: `internal/agent/core/agent.go:2682-2828` -- **问题**: 三个函数共享相同的 base64/data URL 处理、timeout、消息构造逻辑 -- **修复**: 抽取 `mediaRequest(mediaType, prompt, args) → string` 公共方法 - -### P2-4 两处 sources map 硬编码重复 -- **文件**: `internal/config/registry.go:233-244` + `:302-313` -- **问题**: `seedDBValues` 和 `seedCoreDefs` 各写了一遍完全相同的 sources map -- **修复**: 抽取公共 var `defaultSources` - ---- - -## P3 — 低风险(死代码删除/清理) - -### P3-1 删除 messagesToMap 死函数 -- **文件**: `internal/agent/api/provider.go:731-740` - -### P3-2 删除 RunStageAll 死函数 -- **文件**: `internal/agent/core/stages.go:106-108` - -### P3-3 删除 internal/embed/ 整包死代码 -- **文件**: `internal/embed/embedder.go`(162 行,无任何 import) - -### P3-4 删除 internal/tokenizer/jieba.go 死代码 -- **文件**: `internal/tokenizer/jieba.go`(81 行,Global() 从未被调用) - -### P3-5 删除 internal/container/ 整包死代码 -- **文件**: `internal/container/manager.go`(209 行,NewManager 从未被调用) - -### P3-6 删除 internal/snapshot/ 整包死代码 -- **文件**: `internal/snapshot/manager.go`(196 行,import container 但自身也死) - ---- - -## 仓库清理 - -### CL-1 go.work 版本不一致 -- **文件**: `go.work:1` -- **问题**: 声明 `go 1.19` 但 SDK 模块要求 `go 1.21` -- **修复**: 升级到 `go 1.21` - -### CL-2 .gitignore 补充 -- **文件**: `.gitignore` -- **修复**: 添加 `data/` 和 `*.db` - -### CL-3 文档路径修正 -- **文件**: `docs/ARCHITECTURE.md` -- **问题**: L283 `sdk/` 条目应指向 `internal/sdk/` - ---- - ---- - -## C1 — 架构清理:删除 output_set_channel - -> **背景**: 架构原则要求 LLM 主动调用输出工具进行输出。`output_set_channel` 作为一个全局隐式状态, -> 与 `output_send` 的精确指定模式重叠,且 LLM 可能忘记自己设过该状态导致回复走错通道。 - -### C1-1 删除 output_set_channel 工具定义 -- **文件**: `internal/agent/core/agent.go` — `buildToolDefs()` 中删除 `output_set_channel` 的 tool definition block -- **影响**: `output_send` 和 `output_list_channels` 保留,它们的 `chanDesc` 动态生成逻辑不动 - -### C1-2 删除 executeOutputChannelTool 方法 -- **文件**: `internal/agent/core/agent.go` — 删除 `executeOutputChannelTool()` 函数体 - -### C1-3 删除 output_set_channel 路由分支 -- **文件**: `internal/agent/core/agent.go` — `executeToolCall()` 中删除 `case tc.Name == "output_set_channel"` - -### C1-4 更新子 Agent 输出工具黑名单 -- **文件**: `internal/agent/core/agent.go` - - `outputTools` map 中移除 `"output_set_channel"` - - 子工具检查中移除 `ct.Name == "output_set_channel"` - -### C1-5 更新注释 -- **文件**: `internal/agent/core/agent.go:555` — 移除"可能已被 AI 通过 output_set_channel 切换"的注释 - -### C1-6 清理测试文件 -- **文件**: `internal/agent/core/agent_tools_test.go` - - 删除 `TestExecuteOutputChannelTool` 和 `TestExecuteOutputChannelToolEmpty` - - `TestBuildToolDefsOutputToolsAlwaysPresent` 中移除 `output_set_channel` 检查 - - `TestGetAllToolsEmpty` 最小工具数从 3 改为 2 - -### C1-7 验证编译与测试通过 - ---- - -## 执行顺序 - -``` -P0-1 → P0-2 → P2-1 → P2-2 → P2-3 → P2-4 → P3-1~6 → CL-1~3 → C1-1~7 -``` diff --git a/修复计划.md b/修复计划.md deleted file mode 100644 index 3f4c3ce..0000000 --- a/修复计划.md +++ /dev/null @@ -1,142 +0,0 @@ -# HomeAgent 主仓库清理计划 & 代码质量问题修复 - -## 一、仓库清理 - -### 目标 - -将不属于核心仓库的全部内容迁移到 `homeagent-sdk` 仓库,主仓库只保留 -内核 + 内置插件 SDK 桥 (`internal/sdk/`) + 内置插件 (`internal/plugins/`)。 - -### 迁移内容 - -#### → 移入 homeagent-sdk - -``` -示例插件: sdk/example/a2a/ → example/a2a/ - sdk/example/ocr/ → example/ocr/ - sdk/example/bili/ → example/bili/ - sdk/example/editdoc/ → example/editdoc/ - sdk/example/files/ → example/files/ - sdk/example/memo/ → example/memo/ - sdk/example/qq/ → example/qq/ (增强版替换旧版) - sdk/example/web/ → example/web/ -工具链: sdk/tools/ → hack/plugin-dev/ (含 scaffold/build/package/templates) -``` - -> **验证状态**: `sdk/`、`dist/`、`scripts/build-plugins.sh`、`tools/plugin-dev/` 已在当前 repo 中不存在或为空。 -> 以下条目已实际完成,标记为验证通过。 - -#### 主仓库删除 - -``` -sdk/ — 整目录删除(内容已迁入 homeagent-sdk) -tools/plugin-dev/ — 整目录删除(已 DEPRECATED) -scripts/build-plugins.sh — 删除(与"外部插件只依赖 homeagent-sdk"冲突) -dist/ — 整目录删除(.hmap 应由 SDK 仓库构建产出) -config/config.yaml — 删除,代码不从 yaml 读配置,全走 SQLite config.db -``` - -#### 主仓库需修复的无用参数/文档 - -``` -deploy/homeagent.service — 去掉 ExecStart 中的 -config 参数(二进制不识别的 flag) -Makefile install 目标 — 去掉 cp config/config.yaml 步骤 -docs/ADAPTER.md:102 — 去掉"在 config.yaml 中添加对应源"的指引,改为 SQLite 配置方式 -``` - -#### 主仓库需更新的文件 - -``` -README.md — 目录树删除 sdk/ 一行 -docs/ARCHITECTURE.md — 目录树 `sdk/` 条目 (L283) 实际指向 `internal/sdk/`,且当前无顶层 sdk/ 目录;改为 `internal/sdk/` -docs/OVERVIEW.md — 更新 PluginSDK 路径引用 -docs/PLUGIN_DEV.md — 更新外部插件路径引用 -go.mod — SDK 模块标记已为 `// direct`,经验证无需修改 -go.work — 声明 `go 1.19` 但 SDK 模块要求 `go 1.21`,版本不一致;若实际 Go 工具链 >= 1.21 则无影响 -.gitignore — 添加 data/ 和 *.db 到忽略规则 -``` - -#### 主仓库不动的内容 - -``` -internal/sdk/ — 内置插件 SDK 桥(9 个文件,零改动) -internal/plugin/ — 插件系统核心(registry/dynamic/manifest) -internal/plugins/ — 10 个内置插件 -cmd/ — 内核入口 -其余内核代码 — 全部不动 -``` - -### 执行步骤(仓库清理) - -1. 打包待迁移文件为迁移包(供 homeagent-sdk 仓库使用) -2. 删除 sdk/、tools/plugin-dev/、scripts/build-plugins.sh、dist/、config/config.yaml -3. 更新 Makefile(删除外部插件相关目标) -4. 更新文档(README / ARCHITECTURE / OVERVIEW / PLUGIN_DEV) -5. 更新 .gitignore 和 go.mod -6. 验证编译和测试通过 - ---- - -## 二、代码质量问题修复(按优先级) - -### P0 — 必须修(功能正确性) - -> **架构前置**:本工程将插件划分为**内部插件** (`internal/plugins/`) 和**外部插件**(通过 `homeagent-sdk` 独立编译)。内部插件通过 `internal/sdk/` 获取**完整内核 API**(含 EventBus、完整 IOManager、RegisterChannel 等);外部插件通过 `homeagent-sdk/sdk` 使用**公开子集 API**(仅 3 个 IO 方法 + Tool/Stage/API 注册 + 记忆访问)。类似 Linux 内核模块 vs 用户态程序。 -> -> **工具定义验证**: `buildToolDefs()` 从四路汇聚工具:IO 层、插件注册、Indexer 的 `GetToolDefinitions()` (`memory_recall/commit/introspect`)、直接定义(`memory_merge/delete/purge/edit/block_merge` 等)。经查验各源工具名无重叠,不存在重复注册问题。此条移除。 - -- [ ] **HTTP 401/403 检测靠字符串搜索** `internal/agent/core/agent.go:708-711` - - `strings.Contains(errStr, "401")` 不可靠,如 TLS 错误含 `tls: bad certificate` 不匹配 401,但 `"401 Unauthorized"` 检查仍然脆弱。Lua Adapter 返回格式不固定。 - - **修复**: `ProviderManager` 增加 `ReportStatus(name, statusCode)` 方法,在 `LuaAdaptedProvider.Chat()` 中根据 `resp.StatusCode` 精确判断。 - -- [ ] **deploy/homeagent.service 传递 -config 参数使 homed 启动崩溃** `deploy/homeagent.service:9` - - `ExecStart=/usr/local/bin/homed -config /etc/homeagent/config.yaml -data /var/lib/homeagent` - - `cmd/homed/main.go` 只定义了 `-data`, `-webui`, `-socket` 三个 flag,未定义 `-config` - - Go 的 `flag.Parse()` 遇到未定义 flag 会报错并 `os.Exit(2)`,服务无法启动 - - **修复**: 删除 `-config` 参数(已在 Part 1 清理中列出),但此问题实际为 P0 运行时故障 - -### P1 — 高风险(可能崩溃或数据丢失) - -> **验证纠正**: 此前报告的 sql.Rows 双重 Close 和 nil f.Close() 经重新审查确认非真实 bug。 -> - graph.go:266-288: 关键词循环中无 defer 冲突,`rows.Close()` 在显式路径上只调用一次 -> - pipeline.go:138-171: `os.Open` 失败后 `continue` 跳出循环,`defer f.Close()` 所在的匿名函数永不执行 -> -> 本优先级经验证后无真实 issue,保留空位供后续发现。 - -### P2 — 中风险(性能/维护性) - -- [ ] **context.go 每次 Append/Prune 全量写盘** `internal/agent/core/context.go:88-101` - - 30 条事件的 JSON 全量写入文件每次操作,高频输入场景有 I/O 瓶颈。 - - **修复**: 增加定时写入(每 5s flush)或在 `save()` 中增加 debounce。 - -- [ ] **config.yaml 与代码不一致** `config/config.yaml` vs `internal/config/registry.go` - - config.yaml 声明 8 个 LLM sources,代码 `seedDBValues` 只注册 1 个(deepseek)。且代码不从 yaml 读配置(从 SQLite config.db 读取)。 - - **修复**: 删除 config/config.yaml(无用文件),或保持 yaml 作为 fallback 种子的唯一来源并删除代码中的 seedDBValues。 - -- [ ] **output_set_channel 枚举硬编码** `internal/agent/core/agent.go:1927-1928` - - channel enum 硬编码为 `{"voice", "email", "screen", "http"}`,但 `executeOutputSendTool` 却从已注册 Device 动态检测能力,两者脱节。 - - **修复**: 从 `a.io.ListChannels()` 动态生成 enum。 - -- [ ] **describe_image/transcribe_audio/ocr_image 三重复代码** `internal/agent/core/agent.go:2682-2828` - - 三个函数共享相同的 base64/data URL 处理、timeout、消息构造逻辑。 - - **修复**: 抽取 `mediaRequest(mediaType, prompt, args) → string` 公共方法。 - -- [ ] ~~**IOManager 9 个注入方法** `internal/agent/io/channel.go:177-258`~~ - - ~~`InjectInput/To/Sync/SyncTo/Text/TextSync/TextTo/TextNoMemoryTo/TextSyncNoMemoryTo`,组合爆炸级膨胀。~~ - - **经架构审查移除**: `internal/sdk/` 对内部插件暴露完整 IOManager(9 方法),而外部插件仅通过 `homeagent-sdk/sdk` 的 `IOInjector` 接口(3 方法:`InjectInterruptText`/`InjectText`/`InjectTextNoMemory`)访问。9 个方法为内部插件所需的全量 API,属合理设计。 - -- [ ] **两处 sources map 硬编码重复** `internal/config/registry.go:233-244` + `:302-313` - - `seedDBValues` 和 `seedCoreDefs` 各写了一遍完全相同的 sources map。 - - **修复**: 抽取公共 var `defaultSources`。 - -### P3 — 低风险(清理/规范化) - -- [ ] **messagesToMap 死函数** `internal/agent/api/provider.go:731-740` — 从未被调用,删除 -- [ ] **RunStageAll 死函数** `internal/agent/core/stages.go:106-108` — 仅包装 RunStage,删除 -- [ ] **RemoveRelation 方法未暴露为工具** `internal/memory/social/social.go:174-185` — 定义但无 LLM 工具入口 -- [ ] **distillOnce 线性扫描** `internal/memory/pipeline/pipeline.go:190-222` — 每次全表扫描 O(n),可改为维护 distiller index -- [ ] **internal/embed/embedder.go 整包死代码** — `OllamaEmbedder` 和 `HashEmbedder` 定义完整,但没有任何 Go 包 import 或构造它们。记忆系统使用 `vector.TFIDFVectorizer`。删除 embedder.go -- [ ] **internal/tokenizer/jieba.go 整文件死代码** — 基于 `gojieba` 的分词器,但没有任何包 import `tokenizer.Global()`。分词逻辑未在任何路径中调用。删除 jieba.go -- [ ] **internal/container/ 整包死代码** — `container.Manager` 仅被 `internal/snapshot/` import,而 snapshot 本身也是死代码。`container.NewManager` 从未被调用。删除整包 -- [ ] **internal/snapshot/ 整包死代码** — `snapshot.Manager` 从未被任何包 import。`snapshot.NewManager` 从未被调用。main.go 只创建了 `data/snapshots` 目录但未实例化管理器。删除整包 -- [ ] **.tmp-plugins/qq/plugin.go 开发中内部插件** — 1051 行 QQ 插件,位于 `.tmp-plugins/`(在 `.gitignore` 中)。import `internal/sdk` 路径(正确——作为内部插件开发),但由于放在 gitignore 目录下,不会被纳入源码管理。是待完成/废弃的开发实验品。`homeagent-sdk/example/qq/` 已有其外部插件版本。