chore: replace hack with tools and remove obsolete artifacts

- Rename hack/ to tools/ for formal naming
- Update plugin development and deployment script references
- Remove obsolete DESIGN/MILESTONE/PLAN docs now superseded by docs/
- Remove abandoned internal/onebot implementation
- Ignore stray local qq binary artifact
This commit is contained in:
root
2026-07-06 20:46:32 +08:00
parent eb55a8fb98
commit 5fd8d04b1e
18 changed files with 512 additions and 1128 deletions

2
.gitignore vendored
View File

@ -6,4 +6,4 @@ build/
plugins/*/plugin.so plugins/*/plugin.so
.tmp-plugins/ .tmp-plugins/
third_party/homeagent-sdk/.git/ qq

View File

@ -1,13 +0,0 @@
# HomeAgent 架构设计
完整架构文档参见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
实施计划参见 [PLAN.md](PLAN.md)。
## 核心原则
- **核心零 IO** — 无任何硬编码 IO 能力
- **输出是工具调用** — Agent 必须显式调用 output_send 才能通信
- **三通道插件** — 工具 (RegisterTool)、阶段 (RegisterStage)、事件 (Subscribe/Publish)
- **阶段管道** — 7 个 hook 点让插件干预消息处理流
- **三层记忆** — Context (内存) → Document (JSON+向量) → Graph (SQLite)
- **知识独立** — 独立 TF-IDF 向量索引,不与记忆耦合

View File

@ -1,85 +0,0 @@
# MILESTONE — 项目进展与路线图
## 已完成
### 插件系统基础
- [x] agentcli 插件6 个 PTY 终端工具create/write/read/resize/close/list完整按键映射
- [x] cmd 插件:`cmd_run` 工具command/timeout/workdir8 单元测试
- [x] timer 插件:定时器工具
- [x] 12 个跨插件集成测试
### 非记忆 LLM 调用机制
- [x] `StageContext.NoMemory` 标记 — agent 跳过 `emitMemoryCandidate`
- [x] `InjectTextNoMemoryTo` / `InjectTextSyncNoMemoryTo` — IOManager 层
- [x] `InjectTextNoMemory` / `InjectTextSyncNoMemory` — PluginSDK 层
- [x] `ProviderManager.QuickChat()` — 直连 Provider 的快捷调用
### LLM 驱动健康检查(`healthcheck` 插件)
- [x] `healthcheck_report` 工具 — LLM 上报测试结果
- [x] `testLLMDriven()` — 独立工具循环LLM 发现→调用→上报
- [x] 动态排除本插件工具(`selfToolNames`),不硬编码插件名
- [x] 核心遵循 OpenAI `/chat/completions` 请求格式8 个 Lua adapter 脚本支持各厂商 APIDeepSeek/OpenAI/Anthropic/Gemini/Mistral/Groq/GitHub/Ollama用户可编写自定义 adapter 接入任意 LLM
### 内核状态接口(`StatusProvider`
- [x] `internal/agent/core/status.go``KernelStatus` 聚合快照
- [x] `Agent.GetKernelStatus()` — 实现 `StatusProvider` 接口
- [x] `healthcheck_kernel` 工具 — Agent 可自主查询内核状态
- [x] WebUI `/api/v1/kernel` 端点 — 状态 JSON API
- [x] 所有子系统plugins / tools / channels / memory / knowledge / documents / text_memory / social / skills / LLM / runtime / tracker
### 技术债务清理
- [x] 移除旧 `test_deepseek` 插件
- [x] 修复 agentcli PTY readLoop 死锁goroutine reader + close 顺序)
- [x] 修复 knowledge.Store.Delete 不存在方法
### P0 — WebUI 重构
- [x] 完整的 SPA 仪表盘7 标签页:概览/对话/插件/记忆/知识/设置/内核)
- [x] 深色主题,响应式布局
- [x] 使用 `//go:embed dashboard.html` 替代硬编码 HTML 变量
- [x] 全部 REST API 端点保持兼容
### P1 — OpenClaw 兼容插件Go 插件 + Node.js sidecar + 模拟器架构)
- [x] 设计 Go ↔ Node.js 通信协议JSON-RPC 2.0 over stdio
- [x] Go 侧 sidecar 管理器(进程启动、心跳、重启、`waitReady`
- [x] 工具发现(`tools/list`)和执行(`tools/call`)转译
- [x] Node.js 测试插件 `echoplugin`echo/add/ping 三个工具)
- [x] 5 个端到端测试(无 main.js 兜底、列表、调用 echo/add、不存在的工具、并发调用
- [x] 现有 SKILL.md 加载路径保留三通道SKILL.md / sidecar / simulator
- [x] Node.js OpenClaw 模拟进程(`simulator/main.js`):完整实现 `OpenClawPluginApi`,加载任意遵循 OpenClaw 插件标准的真实插件
- [x] 模拟器内嵌于 Go 二进制(`//go:embed`),启动时自动提取
- [x] 支持 `package.json#openclaw.extensions/runtimeExtensions` 入口发现,兼容 `.ts``.js` 编译回退
- [x] 用 ClawHub 真实插件chart-plot验证端到端链路通过
### P2 — 进一步优化
- [x] Healthcheck 定时自动执行(`startAutoCheck` goroutine默认 30 分钟)
- [x] Agent 自主健康状态感知(通过 `healthcheck_kernel`
- [x] 更多 WebUI 可视化面板7 标签页 SPA
- [x] 性能监控与告警(`healthcheck_perf` 工具 + `PerfData` 历史记录 100 条)
---
## 当前状态
所有 P0 / P1 / P2 计划项已完成。项目处于维护和迭代阶段。
### 已注册的工具14 个)
- `cmd_run` — 命令执行
- `terminal_create / write / read / resize / close / list` — PTY 终端
- `healthcheck` — 全面健康检查(含 LLM 驱动阶段)
- `healthcheck_plugins` — 列出已加载插件
- `healthcheck_tools` — 列出所有已注册工具
- `healthcheck_memory` — 图记忆系统测试
- `healthcheck_report` — LLM 汇报接口
- `healthcheck_kernel` — 内核状态快照
- `healthcheck_perf` — 性能监控数据
- `timer_set` — 定时器
### 内置插件7 个)
- agentcli — PTY 终端管理
- cli — Unix socket 通信
- cmd — 命令执行
- healthcheck — 健康检查 + 性能监控 + 自动调度
- mcp — MCP 协议支持
- openclaw — SKILL.md + Node.js sidecar 双通道
- webui — HTTP 服务器 + SPA 仪表盘
- timer — 定时器

297
PLAN.md
View File

@ -1,297 +0,0 @@
# HomeAgent 架构与实施
## 架构概览
```
┌─────────────────────────────────────────────────────────────────────┐
│ homed (内核) │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌───────────────────┐ │
│ │LLM源管理 │ │Agent编排 │ │记忆管理 │ │知识管理 (TF-IDF) │ │
│ │Lua适配器 │ │主agent │ │图/文本/ │ │ │ │
│ │(协议转换) │ │子agent │ │文档三层 │ │ │ │
│ └──────────┘ └──────────┘ └────────────┘ └───────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ IO通道管理器 (IOManager) │ │
│ │ 排队通道 (Queue) + 中断通道 (Interrupt) │ │
│ │ 核心回环 _consolidation_ (记忆消歧/系统维护) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段管道 (StageHost 7 阶段, 并行执行) + 事件总线 (EventBus) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ PluginSDK — 内核对插件的完整 Go API │ │
│ │ IO/工具/阶段/事件/记忆/知识/LLM/配置 │ │
│ └──────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ 内置插件 (编译入内核, init() 自注册) │
│ internal/plugins/all.go (空白导入触发 init) │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ WebUI │ │ CLI │ │ OpenClaw │ │ Timer │ │
│ │ HTTP 服务│ │ Unix socket│ │SKILL.md→SDK│ │ timer_set 工具│ │
│ └──────────┘ └──────────┘ └────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ 动态插件 (<data>/plugins/<name>/ 按需加载) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ plugin.json 元数据 + plugin.so (Go -buildmode=plugin) │ │
│ │ 或 main.lua (Lua 脚本, 预留) │ │
│ │ Registry.Load() 自动扫描, 无 factory → tryLoadSO → tryLoadLua │ │
│ └──────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ waiter (通用客户端) │
│ waiter -say "你好" → Unix socket → CLI 插件 │
│ waiter (交互模式) → 同上 │
└─────────────────────────────────────────────────────────────────────┘
```
## 核心原则
1. **核心零 IO** — homed 不监听端口, 不启动 HTTP 服务, 不读 stdin
2. **一切外界交互都是插件** — 通过 PluginSDK 与核心交互
3. **插件自注册** — 每个插件的 `init()` 调用 `plugin.RegisterFactory()`, 内核无需硬编码
4. **动态 .so 加载** — 第三方插件编译为 `.so`, 放入 `plugins/<name>/plugin.so`
5. **PluginSDK 是内核"系统调用"** — 插件只能通过 SDK 访问核心能力
6. **Stage 并行执行** — 同阶段所有 handler goroutine 并发, StageContext 内嵌 RWMutex
7. **中断最高优先级** — 独立 `interceptLoop` 可随时打断进行中的 LLM 请求
8. **子 agent 是主 agent 的工具**`spawn_child`/`child_result`, 不是 SDK 部分
## 插件注册体系
### 两种注册路径
| 路径 | 场景 | 实现 |
|------|------|------|
| **自注册 (init)** | 内置插件 (timer/cli/openclaw/webui) | 包 `init()``plugin.RegisterFactory(name, factory)` |
| **动态加载** | 第三方插件 | 扫描 `<data>/plugins/<name>/`, 读 manifest + .so |
### 自注册流程
```
internal/plugins/timer/plugin.go
func init() {
plugin.RegisterFactory("timer", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
internal/plugins/all.go
package plugins
import ( _ "timer" _ "cli" _ "openclaw" _ "webui" )
// 空白导入触发所有 init() → RegisterFactory
cmd/homed/main.go
cli.DefaultSocket = *cliSocket // 注入运行时变量
openclaw.SkillsDir = filepath.Join(...)
webui.Configure(httpAddr, sup, mem, sk, ...)
pluginReg.Load(plgDir) // 自动创建目录 + 加载
```
### 动态 .so 加载
插件目录结构:
```
<data>/plugins/myplugin/
plugin.json — 元数据 {name, version, description, author, entry}
plugin.so — Go -buildmode=plugin 编译, 导出 NewPlugin(name, config)
```
动态加载器 `internal/plugin/dynamic.go` 扫描 `.so`:
```go
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
p, _ := plugin.Open(filepath.Join(dir, "plugin.so"))
sym, _ := p.Lookup("NewPlugin")
fn := sym.(func(string, map[string]interface{}) (sdk.Plugin, error))
return fn(name, config), nil
}
```
内置插件也可剥离为 .so, 当前保持 init 自注册。
## 输入双通道 + 中断打断
### 通道结构
```
IOManager
├── inputCh (chan *InputEvent, 256) — 排队通道, 按序处理
├── interruptCh (chan *InputEvent, 64) — 中断通道, 可打断 LLM
└── outputCh (chan *OutputEvent, 256) — 输出通道
```
### 中断打断机制
```
timer 插件 interceptLoop (独立 goroutine)
│ │
├─ s.InjectInterruptText(...) ─────────┤
│ │
│ ┌──────────┴──────────┐
│ │ (a) cancelLLM() │ → 取消进行中的 HTTP 请求
│ │ (b) interceptCh <- │ → process() 非阻塞读取
│ │ (c) InjectInput(...) │ → 空闲时 eventLoop 消费
│ └─────────────────────┘
│ │
▼ ▼
interruptCh process() 工具循环
每个 turn 开始前:
drainInterrupt() → 注入 [打断消息] system msg
```
**三种投递路径 (interceptLoop)**:
- **(a)** `cancelLLM()` — 直接取消当前 Provider HTTP 请求, 捕获 `context.Canceled`
- **(b)** `interceptCh <- text``process()` 每轮 LLM 调用前 `drainInterrupt()`, 注入 `[打断消息]` 到上下文
- **(c)** `InjectInput("interrupt", "text", ...)``eventLoop` 在空闲时收到新输入, 启动新处理循环
## 阶段管道 (Stage Pipeline)
7 个阶段, **并行执行**:
```
on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output
```
| 阶段 | 触发时机 | 插件读写权限 | 典型用途 |
|---|---|---|---|
| `on_input` | 消息到 Agent零处理 | 可读写 `raw_message`,可设置 `response` 短路 | 黑名单、限流、自定义指令前缀 |
| `pre_action` | Memory+Context 就绪LLM 调用前 | 可读写 `context_messages`(追加/修改) | 注入 RAG 结果、插入时政 context |
| `post_action` | LLM 返回文本 + 工具调用列表 | 可读写 `llm_text``tool_calls``context_messages` | 敏感词过滤、强制 redirect 工具 |
| `before_toolcall` | 单个工具调用执行前 | 可读写 `tool_call.name``tool_call.args`,设置 `deny=true` 拒绝 | 审计高危操作、OS 命令白名单 |
| `after_toolcall` | 单个工具执行完毕 | 可读写 `tool_result` | 脱敏数据库结果、排序搜索结果 |
| `before_output` | 最终文本就绪output_send 前 | 可读写 `final_text`,可设置 `skip_output=false` | 添加表情/at 前缀、多平台格式适配 |
| `after_output` | output_send 已调用 | 只读 `final_text` | 统计日志、触发后续流程 |
## 配置体系 (ConfigRegistry)
全部配置持久化在 SQLite:
| 表 | 用途 | 访问 |
|----|------|------|
| `config` | 核心配置 (LLM/daemon/agent/paths) | SettingsAPI.GetCore/SetCore |
| `config_<plugin>` | 插件独立配置 | SettingsAPI.Get/Set/List |
| | 跨插件读写 | GetPlugin/SetPlugin/ListPlugin/Dump |
### 配置元信息 (ConfigDef)
每个配置项注册时附带元数据类型、中文描述、分类、选项等WebUI/CLI 自动发现并渲染。
## 插件包系统
### 包格式 (.hmap)
标准 ZIP 文件,扩展名 `.hmap` (HomeAgent Plugin Package):
```
myplugin-1.0.0.hmap
├── plugin.json 必要 — {name, version, entry, description, author, ...}
├── plugin.so Go 插件 (entry = "plugin.so")
├── main.lua Lua 插件 (entry = "main.lua")
├── SKILL.md Skill 插件 (entry = "SKILL.md")
├── skill.json 可选 — 默认配置
└── assets/ 可选 — 插件资源
```
### pluginmgr 内置插件
| 工具 | 功能 | 关键参数 |
|------|------|---------|
| `plugin_install` | 从 URL 安装 `.hmap` | `{url: string}` |
| `plugin_list` | 列出已安装外部插件 | `{}` |
| `plugin_remove` | 卸载 | `{name: string}` |
| `plugin_info` | 详情(含文件清单) | `{name: string}` |
HTTP API`127.0.0.1:{随机端口}`,默认无鉴权):
| 方法 | 路径 | 作用 |
|------|------|------|
| `GET` | `/plugins` | 列表 |
| `GET` | `/plugins/{name}` | 详情 |
| `POST` | `/plugins` | 安装JSON `{url}` 或二进制 .hmap 上传) |
| `DELETE` | `/plugins/{name}` | 卸载 |
## 内核入口 (cmd/homed/main.go)
初始化顺序:
```
1. 确定 dataDir (默认 ./data/ ,二进制同级)
2. 创建目录结构 (plugins/memory/knowledge/...)
3. 初始化 SQLite ConfigRegistrySeedDefaults 写入默认路径
4. 基础设施 → 记忆/技能/Lua/监督/追踪/IO/事件
5. LLM Provider 管理
6. 阶段管道 StageHost + 插件注册表 Registry
7. 注入内置插件依赖 (cli/webui/healthcheck/pluginmgr)
8. Registry.Load(plgDir) → 自注册 + 动态加载
9. Agent 启动 (eventLoop + interceptLoop + distillLoop)
10. 等待信号 → 关机
```
### 目录结构
```
cmd/
homed/main.go — 内核入口 (零 IO)
waiter/main.go — CLI 客户端 (Unix socket)
internal/
sdk/ ★ PluginSDK (Go API)
plugin.go — Plugin 接口 + PluginSDK 结构体
memory.go / knowledge.go — 记忆/知识包装
settings.go / llm.go — 配置/LLM 源
agent/
core/
agent.go — Agent: eventLoop/interceptLoop/process
context.go — RelevanceContext (TF-IDF)
stages.go — StageHost (并行阶段管道)
api/provider.go — Provider 接口 + LuaAdaptedProvider
io/channel.go — IOManager (Queue/Interrupt/Output)
plugin/
registry.go — 注册表: 生命周期, Load, RegisterFactory
manifest.go — PluginManifest (plugin.json)
dynamic.go — .so 动态加载器
plugin.go — SKILL 插件解析
plugins/
all.go — 空白导入触发所有内置插件 init()
timer/ cli/ openclaw/ webui/ pluginmgr/ — 内置插件
events/bus.go — 系统事件总线
memory/ — 三层记忆 (Context→Document→Graph)
knowledge/ — 知识库
config/registry.go — 配置中心
tracker/ — overlayfs 变更追踪
supervisor/ — 守护管理
skill/ — 技能管理
lua/vm.go — Lua VM (LLM 协议适配)
pkg/types/ — 类型定义
docs/ARCHITECTURE.md — 完整架构文档
```
## 目录路径配置化
### SeedDefaults 新增路径配置
| Key | 默认值 | 说明 |
|-----|--------|------|
| `core.plugin.dir` | `<dataDir>/plugins` | 插件安装目录 |
| `core.memory.graph` | `<dataDir>/memory/graph.db` | 图数据库 |
| `core.memory.text` | `<dataDir>/memory/text` | 文本记忆目录 |
| `core.memory.documents` | `<dataDir>/memory/documents` | 文档记忆目录 |
| `core.knowledge.path` | `<dataDir>/knowledge` | 知识库目录 |
| `core.skills.path` | `<dataDir>/skills` | 技能目录 |
| `core.log.path` | `<dataDir>/log` | 日志目录 |
所有路径配置项均有 `ConfigDef` 元信息 + `password` 类型保护敏感字段。
### 裸二进制启动
- `-data` 默认值从硬编码 `/var/lib/homeagent` 改为 `""`(自动检测)
- 自动检测:读取 `/proc/self/exe` 确定二进制所在目录 → `filepath.Join(exeDir, "data")`
- 首次运行自动创建完整目录结构
- 后续通过调整 ConfigRegistry 的值自定义各存储路径
## 构建与验证
```bash
make build # 编译 homed + waiter
./build/homed -data /tmp/ha # 启动
./build/waiter -say "你好" # 发送消息
```
要求: Go 1.19+, CGo (go-sqlite3), Linux (Unix socket + overlayfs).

View File

@ -10,7 +10,7 @@ SDK 仓库。
```bash ```bash
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
cd homeagent-sdk cd homeagent-sdk
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin tools/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
``` ```
每个插件实现一个三方法接口: 每个插件实现一个三方法接口:
@ -363,7 +363,7 @@ pluginReg.Load(plgDir) // 之后调用
```bash ```bash
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
cd homeagent-sdk cd homeagent-sdk
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin tools/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
``` ```
生成的代码: 生成的代码:
@ -421,7 +421,7 @@ cd plugins/myplugin && make
使用 SDK 仓库的打包工具生成 `.hmap` 分发包: 使用 SDK 仓库的打包工具生成 `.hmap` 分发包:
```bash ```bash
hack/plugin-dev/packager.sh plugins/myplugin tools/plugin-dev/packager.sh plugins/myplugin
# 输出: dist/myplugin-0.1.0.hmap # 输出: dist/myplugin-0.1.0.hmap
``` ```

View File

@ -1,250 +0,0 @@
package onebot
import (
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
// EventHandler 处理 OneBot 推送的事件
type EventHandler func(event *Event)
// Client 是 OneBot 反向 WebSocket 客户端
// 连接到 OneBot 兼容前端(如 go-cqhttp、Lagrange的 WS 地址
type Client struct {
url string
accessToken string
conn *websocket.Conn
mu sync.Mutex
done chan struct{}
eventHandler EventHandler
// 等待响应的 Action 调用
pending sync.Map
echoCount int64
connected atomic.Bool
}
// NewClient 创建 OneBot 反向 WS 客户端
// wsURL: ws://host:port/onebot/v11/ws
func NewClient(wsURL, accessToken string) *Client {
return &Client{
url: wsURL,
accessToken: accessToken,
done: make(chan struct{}),
}
}
// SetEventHandler 注册事件处理函数
func (c *Client) SetEventHandler(h EventHandler) {
c.eventHandler = h
}
// Connected 返回是否已连接
func (c *Client) Connected() bool {
return c.connected.Load()
}
// Connect 连接到 OneBot 前端(阻塞直到连接建立或失败)
func (c *Client) Connect() error {
header := make(map[string][]string)
if c.accessToken != "" {
header["Authorization"] = []string{"Bearer " + c.accessToken}
}
conn, _, err := websocket.DefaultDialer.Dial(c.url, header)
if err != nil {
return err
}
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
}
c.conn = conn
c.mu.Unlock()
c.connected.Store(true)
log.Printf("[onebot] connected to %s", c.url)
go c.readLoop()
return nil
}
// reconnect 自动重连
func (c *Client) reconnect() {
c.connected.Store(false)
backoff := time.Second
for {
select {
case <-c.done:
return
case <-time.After(backoff):
log.Printf("[onebot] reconnecting in %v...", backoff)
if err := c.Connect(); err != nil {
log.Printf("[onebot] reconnect failed: %v, retry", err)
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
continue
}
log.Printf("[onebot] reconnected")
return
}
}
}
func (c *Client) readLoop() {
defer c.connected.Store(false)
for {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
log.Printf("[onebot] read loop: not connected")
go c.reconnect()
return
}
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("[onebot] read error: %v", err)
go c.reconnect()
return
}
// 尝试解析为 ActionResponse有 echo 字段)
var resp ActionResponse
if err := json.Unmarshal(message, &resp); err == nil && resp.Echo != "" {
if ch, ok := c.pending.Load(resp.Echo); ok {
select {
case ch.(chan *ActionResponse) <- &resp:
default:
}
}
continue
}
// 解析为 Event
var evt Event
if err := json.Unmarshal(message, &evt); err != nil {
log.Printf("[onebot] parse error: %v", err)
continue
}
if c.eventHandler != nil {
c.eventHandler(&evt)
}
}
}
// SendAction 发送一个 OneBot API 请求并等待响应
func (c *Client) SendAction(action string, params map[string]interface{}, timeout time.Duration) (*ActionResponse, error) {
echo := atomic.AddInt64(&c.echoCount, 1)
echoStr := formatInt64(echo)
msg := Action{
Action: action,
Params: params,
Echo: echoStr,
}
data, err := json.Marshal(msg)
if err != nil {
return nil, err
}
ch := make(chan *ActionResponse, 1)
c.pending.Store(echoStr, ch)
defer c.pending.Delete(echoStr)
c.mu.Lock()
if c.conn == nil {
c.mu.Unlock()
return nil, fmt.Errorf("not connected")
}
err = c.conn.WriteMessage(websocket.TextMessage, data)
c.mu.Unlock()
if err != nil {
return nil, err
}
if timeout <= 0 {
timeout = 10 * time.Second
}
select {
case resp := <-ch:
if resp.Status == "failed" {
return resp, fmt.Errorf("onebot action %s failed: retcode=%d", action, resp.RetCode)
}
return resp, nil
case <-time.After(timeout):
return nil, fmt.Errorf("onebot action %s timeout", action)
}
}
// SendPrivateMessage 发送私聊消息(便捷方法)
func (c *Client) SendPrivateMessage(userID int64, message interface{}, autoEscape bool) (*ActionResponse, error) {
params := map[string]interface{}{
"user_id": userID,
"message": message,
"auto_escape": autoEscape,
}
return c.SendAction("send_private_msg", params, 0)
}
// SendGroupMessage 发送群消息(便捷方法)
func (c *Client) SendGroupMessage(groupID int64, message interface{}, autoEscape bool) (*ActionResponse, error) {
params := map[string]interface{}{
"group_id": groupID,
"message": message,
"auto_escape": autoEscape,
}
return c.SendAction("send_group_msg", params, 0)
}
// GetLoginInfo 获取登录号信息
func (c *Client) GetLoginInfo() (*ActionResponse, error) {
return c.SendAction("get_login_info", nil, 0)
}
// GetGroupMemberInfo 获取群成员信息
func (c *Client) GetGroupMemberInfo(groupID, userID int64) (*ActionResponse, error) {
return c.SendAction("get_group_member_info", map[string]interface{}{
"group_id": groupID,
"user_id": userID,
}, 0)
}
// GetGroupList 获取群列表
func (c *Client) GetGroupList() (*ActionResponse, error) {
return c.SendAction("get_group_list", nil, 0)
}
// Close 关闭连接
func (c *Client) Close() error {
close(c.done)
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func formatInt64(n int64) string {
return fmt.Sprintf("%d", n)
}

View File

@ -1,220 +0,0 @@
package onebot
import (
"encoding/json"
"fmt"
"log"
"strings"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// Device 将 OneBot 客户端包装为 IO 抽象层的 Device
// 作为 QQ 通道与 HomeAgent 之间的桥梁
type Device struct {
name string
desc string
client *Client
iom *agentIO.IOManager
}
// NewDevice 创建 OneBot IO 设备
// name: 设备名称(如 "qq"
// wsURL: OneBot 前端 WebSocket 地址(如 "ws://127.0.0.1:6700"
// accessToken: OneBot 鉴权令牌(可选)
func NewDevice(name, wsURL, accessToken string, iom *agentIO.IOManager) *Device {
return &Device{
name: name,
desc: fmt.Sprintf("OneBot 标准 QQ 通道 (%s)", wsURL),
client: NewClient(wsURL, accessToken),
iom: iom,
}
}
// Name 返回设备名称
func (d *Device) Name() string { return d.name }
// Type 返回设备类型(双向 IO
func (d *Device) Type() agentIO.DeviceType { return agentIO.DeviceIO }
// Description 返回设备描述
func (d *Device) Description() string { return d.desc }
// OutputCapabilities 返回输出能力(文本+文件+图片)
func (d *Device) OutputCapabilities() agentIO.OutputCapability {
return agentIO.CapText | agentIO.CapFile | agentIO.CapImage
}
// Tools 返回 OneBot 标准 API 的工具定义
// AI 可以通过这些工具调用 OneBot 功能
func (d *Device) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{
{
Name: d.name + "_send_private_msg",
Description: "发送 QQ 私聊消息",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "目标 QQ 号"},
"message": map[string]interface{}{"type": "string", "description": "消息内容(支持 CQ 码,如 [CQ:image,file=xxx.jpg]"},
"auto_escape": map[string]interface{}{"type": "boolean", "description": "是否作为纯文本发送(不解析 CQ 码)"},
},
"required": []string{"user_id", "message"},
},
},
{
Name: d.name + "_send_group_msg",
Description: "发送 QQ 群消息",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "目标群号"},
"message": map[string]interface{}{"type": "string", "description": "消息内容(支持 CQ 码)"},
"auto_escape": map[string]interface{}{"type": "boolean", "description": "是否作为纯文本发送"},
},
"required": []string{"group_id", "message"},
},
},
{
Name: d.name + "_get_group_member_info",
Description: "获取 QQ 群成员信息",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ 号"},
},
"required": []string{"group_id", "user_id"},
},
},
{
Name: d.name + "_get_group_list",
Description: "获取 QQ 群列表",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
}
}
// Execute 执行 OneBot 工具调用
func (d *Device) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if !d.client.Connected() {
return nil, fmt.Errorf("onebot 未连接到前端")
}
// 去掉名称前缀以匹配方法名
method := strings.TrimPrefix(tool, d.name+"_")
switch method {
case "send_private_msg":
userID, _ := toInt64(args["user_id"])
message, _ := args["message"].(string)
autoEscape, _ := args["auto_escape"].(bool)
return d.client.SendPrivateMessage(userID, message, autoEscape)
case "send_group_msg":
groupID, _ := toInt64(args["group_id"])
message, _ := args["message"].(string)
autoEscape, _ := args["auto_escape"].(bool)
return d.client.SendGroupMessage(groupID, message, autoEscape)
case "get_group_member_info":
groupID, _ := toInt64(args["group_id"])
userID, _ := toInt64(args["user_id"])
return d.client.GetGroupMemberInfo(groupID, userID)
case "get_group_list":
return d.client.GetGroupList()
default:
return nil, fmt.Errorf("unknown onebot tool: %s", tool)
}
}
// Start 连接到 OneBot 前端
func (d *Device) Start() error {
// 非阻塞连接
go func() {
if err := d.client.Connect(); err != nil {
log.Printf("[onebot] %s initial connect failed, will retry: %v", d.name, err)
}
}()
// 注册事件处理OneBot 事件 → IO InputEvent
d.client.SetEventHandler(func(evt *Event) {
d.handleEvent(evt)
})
return nil
}
// Stop 断开连接
func (d *Device) Stop() error {
return d.client.Close()
}
// handleEvent 将 OneBot 事件转换为 IO InputEvent
func (d *Device) handleEvent(evt *Event) {
if d.iom == nil {
return
}
switch evt.PostType {
case "message":
var text string
if evt.RawMessage != "" {
text = evt.RawMessage
} else if s, ok := evt.Message.(string); ok {
text = s
}
if text == "" {
return
}
// 构造输入源标识
source := d.name
payload := map[string]interface{}{
"content": text,
"source": source,
"user_id": evt.UserID,
"sender": evt.Sender,
}
if evt.MessageType == "group" {
payload["group_id"] = evt.GroupID
payload["label"] = fmt.Sprintf("group:%d:%d", evt.GroupID, evt.UserID)
} else {
payload["label"] = fmt.Sprintf("private:%d", evt.UserID)
}
d.iom.InjectTextTo(source, d.name, text)
case "notice":
log.Printf("[onebot] notice from %s: type=%s", d.name, evt.NoticeType)
case "request":
log.Printf("[onebot] request from %s: type=%s flag=%s", d.name, evt.RequestType, evt.Flag)
case "meta_event":
if evt.MetaEventType == "heartbeat" {
log.Printf("[onebot] %s heartbeat: online=%v", d.name, evt.Status != nil && evt.Status.Online)
}
}
}
func toInt64(v interface{}) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case float64:
return int64(n), true
case int:
return int64(n), true
case json.Number:
i, err := n.Int64()
return i, err == nil
}
return 0, false
}

View File

@ -1,94 +0,0 @@
// Package onebot 实现 OneBot V11 标准协议Reverse WebSocket 通信)
// 参考: https://github.com/botuniverse/onebot-11
package onebot
import "fmt"
// Action 是 OneBot 标准 API 请求
type Action struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params,omitempty"`
Echo string `json:"echo,omitempty"`
}
// ActionResponse 是 OneBot 标准 API 响应
type ActionResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Data interface{} `json:"data"`
Echo string `json:"echo,omitempty"`
}
// Event 是 OneBot 推送的事件
type Event struct {
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
PostType string `json:"post_type"` // message, notice, request, meta_event
DetailType string `json:"-"`
// 消息事件字段
MessageType string `json:"message_type,omitempty"` // private, group
SubType string `json:"sub_type,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"`
Message interface{} `json:"message,omitempty"` // string 或 []MessageSegment
RawMessage string `json:"raw_message,omitempty"`
Font int `json:"font,omitempty"`
Sender *Sender `json:"sender,omitempty"`
// 通知事件字段
NoticeType string `json:"notice_type,omitempty"`
// 请求事件字段
RequestType string `json:"request_type,omitempty"`
Flag string `json:"flag,omitempty"`
Comment string `json:"comment,omitempty"`
// 元事件字段
MetaEventType string `json:"meta_event_type,omitempty"`
Interval int64 `json:"interval,omitempty"`
Status *Status `json:"status,omitempty"`
}
type Sender struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Sex string `json:"sex,omitempty"`
Age int `json:"age,omitempty"`
Card string `json:"card,omitempty"` // 群名片
Area string `json:"area,omitempty"`
Level string `json:"level,omitempty"`
Role string `json:"role,omitempty"` // owner, admin, member
Title string `json:"title,omitempty"`
}
type Status struct {
AppInitialized bool `json:"app_initialized"`
AppEnabled bool `json:"app_enabled"`
PluginsGood bool `json:"plugins_good"`
AppGood bool `json:"app_good"`
Online bool `json:"online"`
Good bool `json:"good"`
}
// MessageSegment 表示 OneBot 消息段(数组格式)
type MessageSegment struct {
Type string `json:"type"`
Data map[string]string `json:"data"`
}
// MessageText 快速构造纯文本消息段
func MessageText(text string) MessageSegment {
return MessageSegment{Type: "text", Data: map[string]string{"text": text}}
}
// MessageImage 构造图片消息段
func MessageImage(file string) MessageSegment {
return MessageSegment{Type: "image", Data: map[string]string{"file": file}}
}
// MessageAt 构造 @ 消息段
func MessageAt(userID int64) MessageSegment {
return MessageSegment{Type: "at", Data: map[string]string{"qq": fmt.Sprintf("%d", userID)}}
}

View File

@ -1,165 +0,0 @@
package onebot
import (
"encoding/json"
"testing"
)
func TestMessageText(t *testing.T) {
seg := MessageText("hello")
if seg.Type != "text" {
t.Errorf("expected type 'text', got %q", seg.Type)
}
if seg.Data["text"] != "hello" {
t.Errorf("expected data.text 'hello', got %q", seg.Data["text"])
}
}
func TestMessageImage(t *testing.T) {
seg := MessageImage("test.jpg")
if seg.Type != "image" {
t.Errorf("expected 'image', got %q", seg.Type)
}
if seg.Data["file"] != "test.jpg" {
t.Errorf("expected 'test.jpg', got %q", seg.Data["file"])
}
}
func TestMessageAt(t *testing.T) {
seg := MessageAt(123456)
if seg.Type != "at" {
t.Errorf("expected 'at', got %q", seg.Type)
}
if seg.Data["qq"] != "123456" {
t.Errorf("expected '123456', got %q", seg.Data["qq"])
}
}
func TestEventMarshal(t *testing.T) {
evt := Event{
Time: 1234567890,
SelfID: 10001,
PostType: "message",
MessageType: "group",
GroupID: 999,
UserID: 777,
RawMessage: "hello",
Sender: &Sender{
UserID: 777,
Nickname: "TestUser",
Role: "member",
},
}
data, err := json.Marshal(evt)
if err != nil {
t.Fatal(err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.PostType != "message" {
t.Errorf("expected 'message', got %q", decoded.PostType)
}
if decoded.GroupID != 999 {
t.Errorf("expected 999, got %d", decoded.GroupID)
}
if decoded.Sender.Nickname != "TestUser" {
t.Errorf("expected 'TestUser', got %q", decoded.Sender.Nickname)
}
}
func TestEventMessagePrivate(t *testing.T) {
evt := Event{
PostType: "message",
MessageType: "private",
UserID: 123,
RawMessage: "hi",
}
if evt.PostType != "message" || evt.MessageType != "private" {
t.Errorf("unexpected event type: %s/%s", evt.PostType, evt.MessageType)
}
}
func TestActionMarshal(t *testing.T) {
action := Action{
Action: "send_private_msg",
Params: map[string]interface{}{
"user_id": 123,
"message": "hello",
},
Echo: "1",
}
data, err := json.Marshal(action)
if err != nil {
t.Fatal(err)
}
var decoded Action
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Action != "send_private_msg" {
t.Errorf("expected 'send_private_msg', got %q", decoded.Action)
}
}
func TestActionResponse(t *testing.T) {
resp := ActionResponse{
Status: "ok",
RetCode: 0,
Data: map[string]interface{}{
"message_id": 12345,
},
Echo: "1",
}
data, _ := json.Marshal(resp)
var decoded ActionResponse
json.Unmarshal(data, &decoded)
if decoded.Status != "ok" {
t.Errorf("expected 'ok', got %q", decoded.Status)
}
}
func TestStatus(t *testing.T) {
s := Status{
AppInitialized: true,
AppEnabled: true,
Online: true,
Good: true,
}
if !s.Online || !s.Good {
t.Error("status should be online and good")
}
}
func TestEventMetaHeartbeat(t *testing.T) {
evt := Event{
PostType: "meta_event",
MetaEventType: "heartbeat",
Interval: 3000,
Status: &Status{
Online: true,
Good: true,
},
}
if evt.PostType != "meta_event" {
t.Errorf("expected 'meta_event', got %q", evt.PostType)
}
if evt.MetaEventType != "heartbeat" {
t.Errorf("expected 'heartbeat', got %q", evt.MetaEventType)
}
if !evt.Status.Online {
t.Error("should be online")
}
}

47
tools/deploy/deploy.sh Executable file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# HomeAgent 部署脚本
# 用法: cd <project-root> && sudo bash tools/deploy/deploy.sh
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BIN_DIR="/usr/local/bin"
DATA_DIR="/home/newqqagent"
SERVICE_FILE="/etc/systemd/system/homeagent.service"
echo "=== 构建 homed / waiter ==="
cd "$PROJECT_ROOT"
HOME=/root GOPATH=/root/go GOMODCACHE=/root/go/pkg/mod GOCACHE=/root/.cache/go-build make build build-cli
echo "=== 安装二进制 ==="
cp build/homed "$BIN_DIR/homed"
cp build/waiter "$BIN_DIR/waiter"
chmod 755 "$BIN_DIR/homed" "$BIN_DIR/waiter"
echo "=== 创建数据目录 ==="
mkdir -p "$DATA_DIR/plugins"
echo "=== 部署 QQ 插件 ==="
if [ -d "$PROJECT_ROOT/plugins/qq" ]; then
mkdir -p "$DATA_DIR/plugins/qq"
cp "$PROJECT_ROOT/plugins/qq/plugin.json" "$DATA_DIR/plugins/qq/"
cp "$PROJECT_ROOT/plugins/qq/plugin.so" "$DATA_DIR/plugins/qq/"
echo "QQ 插件已部署"
fi
echo "=== 安装 systemd 服务 ==="
cp "$(dirname "$0")/homeagent.service" "$SERVICE_FILE"
systemctl daemon-reload
echo ""
echo "=== 部署完成 ==="
echo ""
echo "启动: systemctl start homeagent"
echo "状态: systemctl status homeagent"
echo "日志: journalctl -u homeagent -f"
echo "停止: systemctl stop homeagent"
echo "WebUI: http://localhost:8080"
echo "CLI: /home/newqqagent/cli.sock"
echo ""
echo "首次使用请通过 WebUI → 设置 配置 API 密钥"
echo "工作目录: 在 WebUI 设置 → core.agent.workdir 中配置(如 /home/newqqagent/workspace"

View File

@ -0,0 +1,13 @@
[Unit]
Description=HomeAgent - 24/7 AI Butler
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/homed -data /home/newqqagent
Restart=always
RestartSec=10
Environment=HOME=/root
[Install]
WantedBy=multi-user.target

61
tools/plugin-dev/packager.sh Executable file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# HomeAgent 插件打包工具
# 将插件目录打包为 .hmap 分发包
# 用法: ./packager.sh <plugin-dir> [输出路径]
# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap
PLUGIN_DIR="${1:-}"
OUTPUT="${2:-}"
if [ -z "$PLUGIN_DIR" ]; then
echo "用法: $0 <plugin-dir> [输出路径]"
echo "示例: $0 ./plugins/myplugin ./dist/myplugin-1.0.0.hmap"
exit 1
fi
PLUGIN_DIR="$(realpath "$PLUGIN_DIR")"
PLUGIN_NAME="$(basename "$PLUGIN_DIR")"
# 验证
if [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
echo "错误: 不存在 plugin.json: $PLUGIN_DIR"
exit 1
fi
VERSION="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['version'])" 2>/dev/null || echo "unknown")"
if [ -z "$OUTPUT" ]; then
mkdir -p dist
OUTPUT="$(realpath "dist/${PLUGIN_NAME}-${VERSION}.hmap")"
fi
echo "🔨 打包插件: $PLUGIN_NAME v$VERSION"
echo " 源目录: $PLUGIN_DIR"
echo " 输出: $OUTPUT"
# 检查入口文件
ENTRY="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['entry'])" 2>/dev/null || true)"
if [ -n "$ENTRY" ] && [ ! -f "$PLUGIN_DIR/$ENTRY" ]; then
echo "⚠️ 入口文件不存在: $ENTRY"
echo " 请先编译: cd $PLUGIN_DIR && make"
exit 1
fi
# 检查已编译的 .so
if [ -f "$PLUGIN_DIR/plugin.so" ] && [ "$(stat -c %Y "$PLUGIN_DIR/plugin.so" 2>/dev/null)" -lt "$(stat -c %Y "$PLUGIN_DIR/plugin.go" 2>/dev/null)" ]; then
echo "⚠️ plugin.so 比 plugin.go 旧,建议重新编译"
echo " 请执行: cd $PLUGIN_DIR && make"
fi
cd "$PLUGIN_DIR"
zip -r "$OUTPUT" . -x "*.git*" "Makefile" ".gitignore" "*.go" "go.mod" "go.sum" "*.test" "testdata/*" "_*" 2>&1 | tail -3
echo ""
echo "✅ 打包完成: $OUTPUT"
echo " 大小: $(ls -lh "$OUTPUT" | awk '{print $5}')"
echo ""
echo "安装方式:"
echo " 1. WebUI 插件管理 → 上传安装"
echo " 2. AI 对话: 使用 plugin_install 工具并上传 URL"

45
tools/plugin-dev/scaffold.sh Executable file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
# HomeAgent 插件脚手架生成工具
# 用法: ./scaffold.sh <plugin-name> [输出目录]
# 示例: ./scaffold.sh myplugin ./plugins/myplugin
NAME="${1:-}"
OUTDIR="${2:-./plugins/$NAME}"
if [ -z "$NAME" ]; then
echo "用法: $0 <plugin-name> [输出目录]"
echo "示例: $0 myplugin ./plugins/myplugin"
exit 1
fi
if [ -d "$OUTDIR" ]; then
echo "错误: 目标目录已存在: $OUTDIR"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/templates"
mkdir -p "$OUTDIR"
# 替换模板中的占位符
sed -e "s/{{.Name}}/$NAME/g" \
-e "s/{{.Version}}/0.1.0/g" \
-e "s/{{.Description}}//g" \
-e "s/{{.Author}}//g" \
"$TEMPLATE_DIR/plugin.json.tmpl" > "$OUTDIR/plugin.json"
cp "$TEMPLATE_DIR/plugin.go.tmpl" "$OUTDIR/plugin.go"
cp "$TEMPLATE_DIR/Makefile.tmpl" "$OUTDIR/Makefile"
cp "$TEMPLATE_DIR/gitignore.tmpl" "$OUTDIR/.gitignore"
echo "✅ 插件脚手架已生成: $OUTDIR"
echo ""
echo "下一步:"
echo " 1. 编辑 $OUTDIR/plugin.go 实现业务逻辑"
echo " 2. 编辑 $OUTDIR/plugin.json 完善元信息"
echo " 3. cd $OUTDIR && make # 编译 plugin.so"
echo " 4. make package # 打包为 .hmap 分发包"
echo " 5. 通过 WebUI 或 plugin_install 工具安装"

View File

@ -0,0 +1,21 @@
# Build external Go plugin for HomeAgent
# Usage: make # build plugin.so
# make clean # remove plugin.so
# make package # build + create .hmap package
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(realpath $(PLUGIN_DIR)../..)
PLUGIN_NAME := $(notdir $(realpath $(PLUGIN_DIR)))
.PHONY: all clean package
all: plugin.so
plugin.so:
cd $(PROJECT_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)
clean:
rm -f $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)$(PLUGIN_NAME).hmap
package: plugin.so
cd $(PLUGIN_DIR) && zip -r $(PLUGIN_NAME).hmap plugin.json plugin.so

View File

@ -0,0 +1,2 @@
plugin.so
*.hmap

View File

@ -0,0 +1,56 @@
package main
import (
"log"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{
name: name,
}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
tp := p.name + "_"
s.RegisterTool(tp+"example", sdk.ToolDef{
Name: tp + "example",
Description: "示例工具 - 请替换为实现",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"input": map[string]interface{}{"type": "string", "description": "输入参数"},
},
"required": []string{"input"},
},
}, p.handleExample)
log.Printf("[%s] plugin started", p.name)
return nil
}
func (p *Plugin) Stop() error {
return nil
}
func (p *Plugin) handleExample(args map[string]interface{}) (interface{}, error) {
input, _ := args["input"].(string)
return map[string]interface{}{
"echo": input,
}, nil
}
func main() {}

View File

@ -0,0 +1,10 @@
{
"name": "{{.Name}}",
"version": "{{.Version}}",
"description": "{{.Description}}",
"author": "{{.Author}}",
"license": "MIT",
"entry": "plugin.so",
"min_version": "1.0.0",
"tags": ["{{.Name}}"]
}

View File

@ -0,0 +1,253 @@
// Package plugintest provides a test harness for external HomeAgent plugins.
//
// Usage:
//
// import "gitcode.com/JianFeeeee/HomeAgent/tools/plugin-dev/testharness"
//
// func TestMyPlugin(t *testing.T) {
// h := testharness.New(t, "./path/to/plugin.so")
// defer h.Close()
//
// result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{
// "input": "hello",
// })
// if err != nil {
// t.Fatal(err)
// }
// t.Logf("result: %v", result)
// }
package plugintest
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"plugin"
"strings"
"sync"
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// Harness is a test harness for loading and testing external Go plugins.
type Harness struct {
t *testing.T
plug sdk.Plugin
sdk *sdk.PluginSDK
mu sync.Mutex
tools map[string]sdk.ToolHandler
stages map[sdk.Stage][]sdk.StageHandler
setting *mockSettings
}
// New loads a plugin .so and starts it with a mock SDK.
// soPath is the path to the compiled plugin.so file.
func New(t *testing.T, soPath string) *Harness {
t.Helper()
absPath, err := filepath.Abs(soPath)
if err != nil {
t.Fatalf("abs path: %v", err)
}
if _, err := os.Stat(absPath); err != nil {
t.Fatalf("plugin not found: %s", absPath)
}
pkg, err := plugin.Open(absPath)
if err != nil {
t.Fatalf("plugin.Open: %v", err)
}
sym, err := pkg.Lookup("NewPlugin")
if err != nil {
t.Fatalf("NewPlugin symbol not found: %v", err)
}
newPlugin, ok := sym.(func(name string, config map[string]interface{}) (sdk.Plugin, error))
if !ok {
t.Fatal("NewPlugin has wrong signature")
}
name := filepath.Base(filepath.Dir(absPath))
plug, err := newPlugin(name, nil)
if err != nil {
t.Fatalf("NewPlugin: %v", err)
}
h := &Harness{
t: t,
plug: plug,
tools: make(map[string]sdk.ToolHandler),
stages: make(map[sdk.Stage][]sdk.StageHandler),
setting: &mockSettings{
data: make(map[string]interface{}),
defs: make(map[string]sdk.ConfigDef),
},
}
mockSDK := sdk.New(
name,
nil, nil, nil, nil, nil, nil, nil,
h.setting,
h.regTool,
h.regStage,
nil,
)
h.sdk = mockSDK
if err := plug.Start(mockSDK); err != nil {
t.Fatalf("plugin.Start: %v", err)
}
return h
}
func (h *Harness) regTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
h.mu.Lock()
defer h.mu.Unlock()
h.tools[name] = handler
return nil
}
func (h *Harness) regStage(stage sdk.Stage, handler sdk.StageHandler) {
h.mu.Lock()
defer h.mu.Unlock()
h.stages[stage] = append(h.stages[stage], handler)
}
// Plug returns the loaded plugin instance.
func (h *Harness) Plug() sdk.Plugin { return h.plug }
// SDK returns the mock PluginSDK.
func (h *Harness) SDK() *sdk.PluginSDK { return h.sdk }
// Settings returns the mock settings store for test assertions.
func (h *Harness) Settings() *mockSettings { return h.setting }
// ToolNames returns all registered tool names.
func (h *Harness) ToolNames() []string {
h.mu.Lock()
defer h.mu.Unlock()
names := make([]string, 0, len(h.tools))
for n := range h.tools {
names = append(names, n)
}
return names
}
// CallTool invokes a registered tool handler with the given arguments.
func (h *Harness) CallTool(name string, args map[string]interface{}) (interface{}, error) {
h.mu.Lock()
handler, ok := h.tools[name]
h.mu.Unlock()
if !ok {
return nil, fmt.Errorf("tool %q not registered", name)
}
return handler(args)
}
// Close stops the plugin.
func (h *Harness) Close() {
if err := h.plug.Stop(); err != nil {
h.t.Logf("plugin.Stop: %v", err)
}
}
// AssertToolRegistered fails if the tool is not registered.
func (h *Harness) AssertToolRegistered(name string) {
h.t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.tools[name]; !ok {
h.t.Fatalf("expected tool %q to be registered", name)
}
}
// AssertToolResult checks that calling a tool returns the expected JSON output.
func (h *Harness) AssertToolResult(name string, args map[string]interface{}, expected map[string]interface{}) {
h.t.Helper()
got, err := h.CallTool(name, args)
if err != nil {
h.t.Fatalf("tool %q: %v", name, err)
}
gotJSON, _ := json.Marshal(got)
expJSON, _ := json.Marshal(expected)
if string(gotJSON) != string(expJSON) {
h.t.Fatalf("tool %q:\ngot: %s\nexp: %s", name, gotJSON, expJSON)
}
}
// mockSettings implements sdk.SettingsAPI for testing.
type mockSettings struct {
mu sync.Mutex
data map[string]interface{}
defs map[string]sdk.ConfigDef
}
func (m *mockSettings) Get(key string) (interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.data[key]
if !ok {
return nil, fmt.Errorf("key %q not found", key)
}
return v, nil
}
func (m *mockSettings) Set(key string, value interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
return nil
}
func (m *mockSettings) List(prefix string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
var keys []string
for k := range m.data {
if prefix == "" || strings.HasPrefix(k, prefix) {
keys = append(keys, k)
}
}
return keys, nil
}
func (m *mockSettings) GetCore(key string) (interface{}, error) { return m.Get(key) }
func (m *mockSettings) SetCore(key string, value interface{}) error { return m.Set(key, value) }
func (m *mockSettings) ListCore(prefix string) ([]string, error) { return m.List(prefix) }
func (m *mockSettings) GetPlugin(plugin, key string) (interface{}, error) { return m.Get(key) }
func (m *mockSettings) SetPlugin(plugin, key string, value interface{}) error { return m.Set(key, value) }
func (m *mockSettings) ListPlugin(plugin, prefix string) ([]string, error) { return m.List(prefix) }
func (m *mockSettings) RegisterDef(def sdk.ConfigDef) {
m.mu.Lock()
defer m.mu.Unlock()
m.defs[def.Key] = def
}
func (m *mockSettings) Defs(prefix string) []*sdk.ConfigDef {
m.mu.Lock()
defer m.mu.Unlock()
var defs []*sdk.ConfigDef
for _, d := range m.defs {
if prefix == "" || strings.HasPrefix(d.Key, prefix) {
defs = append(defs, &d)
}
}
return defs
}
func (m *mockSettings) Dump() map[string]interface{} {
m.mu.Lock()
defer m.mu.Unlock()
cp := make(map[string]interface{})
for k, v := range m.data {
cp[k] = v
}
return cp
}
func (m *mockSettings) Plugins() []string { return nil }