mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
feat: restructure plugin system, add Lua plugin support, update docs
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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` 工具链。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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_<plugin_name> 表)
|
||||
sdk.Settings().Get(key string) (interface{}, error)
|
||||
sdk.Settings().Set(key string, value interface{}) error
|
||||
sdk.Settings().List(prefix string) ([]string, error)
|
||||
```
|
||||
|
||||
### 读取插件配置
|
||||
|
||||
每插件独立 SQLite 表 `config_<name>`:
|
||||
|
||||
```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 <SDK_REPO_ROOT>
|
||||
go build -buildmode=plugin -o plugins/myplugin/plugin.so plugins/myplugin/
|
||||
```
|
||||
|
||||
或使用项目中的 Makefile:
|
||||
|
||||
```bash
|
||||
cd plugins/myplugin && make
|
||||
```
|
||||
|
||||
### 部署
|
||||
|
||||
将插件目录(含 `plugin.json` + `plugin.so`)放入内核配置的插件目录:
|
||||
|
||||
```
|
||||
<dataDir>/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)。*
|
||||
15
go.mod
15
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
|
||||
)
|
||||
|
||||
24
go.sum
24
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=
|
||||
|
||||
4
go.work
4
go.work
@ -1,6 +1,6 @@
|
||||
go 1.21
|
||||
go 1.25.0
|
||||
|
||||
use (
|
||||
.
|
||||
/tmp/opencode/homeagent-sdk-repo
|
||||
../homeagentsdk
|
||||
)
|
||||
|
||||
26
go.work.sum
26
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=
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(短路)
|
||||
|
||||
@ -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.<name>.* 的 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.<name>.* 键并注册 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.<name> 并注册 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
|
||||
|
||||
8
internal/lua/sdk/embeded.go
Normal file
8
internal/lua/sdk/embeded.go
Normal file
@ -0,0 +1,8 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed sdk.lua
|
||||
var SDKSource string
|
||||
204
internal/lua/sdk/sdk.lua
Normal file
204
internal/lua/sdk/sdk.lua
Normal file
@ -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
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
263
internal/plugin/bridge_e2e_test.go
Normal file
263
internal/plugin/bridge_e2e_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
11
internal/plugin/dynamic_dll_stub.go
Normal file
11
internal/plugin/dynamic_dll_stub.go
Normal file
@ -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
|
||||
}
|
||||
32
internal/plugin/dynamic_dll_test.go
Normal file
32
internal/plugin/dynamic_dll_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
272
internal/plugin/dynamic_dll_windows.go
Normal file
272
internal/plugin/dynamic_dll_windows.go
Normal file
@ -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)
|
||||
}
|
||||
24
internal/plugin/dynamic_lua.go
Normal file
24
internal/plugin/dynamic_lua.go
Normal file
@ -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
|
||||
}
|
||||
347
internal/plugin/lua_plugin.go
Normal file
347
internal/plugin/lua_plugin.go
Normal file
@ -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
|
||||
}
|
||||
116
internal/plugin/lua_plugin_test.go
Normal file
116
internal/plugin/lua_plugin_test.go
Normal file
@ -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")
|
||||
}
|
||||
60
internal/plugin/lua_util.go
Normal file
60
internal/plugin/lua_util.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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{} {
|
||||
|
||||
@ -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 {
|
||||
|
||||
21
internal/plugins/agentcli/plugin_stub.go
Normal file
21
internal/plugins/agentcli/plugin_stub.go
Normal file
@ -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 }
|
||||
@ -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 ""
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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",
|
||||
})
|
||||
|
||||
@ -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.<name>.<field>)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
internal/plugins/webui/dashboard2.html
Normal file
1
internal/plugins/webui/dashboard2.html
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
123
plan.md
123
plan.md
@ -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
|
||||
```
|
||||
142
修复计划.md
142
修复计划.md
@ -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/` 已有其外部插件版本。
|
||||
Reference in New Issue
Block a user