mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,4 +6,4 @@ build/
|
|||||||
plugins/*/plugin.so
|
plugins/*/plugin.so
|
||||||
|
|
||||||
.tmp-plugins/
|
.tmp-plugins/
|
||||||
third_party/homeagent-sdk/.git/
|
qq
|
||||||
|
|||||||
13
DESIGN.md
13
DESIGN.md
@ -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 向量索引,不与记忆耦合
|
|
||||||
85
MILESTONE.md
85
MILESTONE.md
@ -1,85 +0,0 @@
|
|||||||
# MILESTONE — 项目进展与路线图
|
|
||||||
|
|
||||||
## 已完成
|
|
||||||
|
|
||||||
### 插件系统基础
|
|
||||||
- [x] agentcli 插件:6 个 PTY 终端工具(create/write/read/resize/close/list),完整按键映射
|
|
||||||
- [x] cmd 插件:`cmd_run` 工具(command/timeout/workdir),8 单元测试
|
|
||||||
- [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 脚本支持各厂商 API(DeepSeek/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
297
PLAN.md
@ -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 ConfigRegistry,SeedDefaults 写入默认路径
|
|
||||||
4. 基础设施 → 记忆/技能/Lua/监督/追踪/IO/事件
|
|
||||||
5. LLM Provider 管理
|
|
||||||
6. 阶段管道 StageHost + 插件注册表 Registry
|
|
||||||
7. 注入内置插件依赖 (cli/webui/healthcheck/pluginmgr)
|
|
||||||
8. Registry.Load(plgDir) → 自注册 + 动态加载
|
|
||||||
9. Agent 启动 (eventLoop + interceptLoop + distillLoop)
|
|
||||||
10. 等待信号 → 关机
|
|
||||||
```
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
cmd/
|
|
||||||
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).
|
|
||||||
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)}}
|
|
||||||
}
|
|
||||||
@ -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
47
tools/deploy/deploy.sh
Executable 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)"
|
||||||
13
tools/deploy/homeagent.service
Normal file
13
tools/deploy/homeagent.service
Normal 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
61
tools/plugin-dev/packager.sh
Executable 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
45
tools/plugin-dev/scaffold.sh
Executable 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 工具安装"
|
||||||
21
tools/plugin-dev/templates/Makefile.tmpl
Normal file
21
tools/plugin-dev/templates/Makefile.tmpl
Normal 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
|
||||||
2
tools/plugin-dev/templates/gitignore.tmpl
Normal file
2
tools/plugin-dev/templates/gitignore.tmpl
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
plugin.so
|
||||||
|
*.hmap
|
||||||
56
tools/plugin-dev/templates/plugin.go.tmpl
Normal file
56
tools/plugin-dev/templates/plugin.go.tmpl
Normal 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() {}
|
||||||
10
tools/plugin-dev/templates/plugin.json.tmpl
Normal file
10
tools/plugin-dev/templates/plugin.json.tmpl
Normal 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}}"]
|
||||||
|
}
|
||||||
253
tools/plugin-dev/testharness/harness.go
Normal file
253
tools/plugin-dev/testharness/harness.go
Normal 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 }
|
||||||
Reference in New Issue
Block a user