From 5fd8d04b1ee3c39b66ab37f6df960dae7df7913b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 20:46:32 +0800 Subject: [PATCH] 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 --- .gitignore | 2 +- DESIGN.md | 13 - MILESTONE.md | 85 ------ PLAN.md | 297 -------------------- docs/PLUGIN_DEV.md | 6 +- internal/onebot/client.go | 250 ---------------- internal/onebot/device.go | 220 --------------- internal/onebot/types.go | 94 ------- internal/onebot/types_test.go | 165 ----------- tools/deploy/deploy.sh | 47 ++++ tools/deploy/homeagent.service | 13 + tools/plugin-dev/packager.sh | 61 ++++ tools/plugin-dev/scaffold.sh | 45 +++ tools/plugin-dev/templates/Makefile.tmpl | 21 ++ tools/plugin-dev/templates/gitignore.tmpl | 2 + tools/plugin-dev/templates/plugin.go.tmpl | 56 ++++ tools/plugin-dev/templates/plugin.json.tmpl | 10 + tools/plugin-dev/testharness/harness.go | 253 +++++++++++++++++ 18 files changed, 512 insertions(+), 1128 deletions(-) delete mode 100644 DESIGN.md delete mode 100644 MILESTONE.md delete mode 100644 PLAN.md delete mode 100644 internal/onebot/client.go delete mode 100644 internal/onebot/device.go delete mode 100644 internal/onebot/types.go delete mode 100644 internal/onebot/types_test.go create mode 100755 tools/deploy/deploy.sh create mode 100644 tools/deploy/homeagent.service create mode 100755 tools/plugin-dev/packager.sh create mode 100755 tools/plugin-dev/scaffold.sh create mode 100644 tools/plugin-dev/templates/Makefile.tmpl create mode 100644 tools/plugin-dev/templates/gitignore.tmpl create mode 100644 tools/plugin-dev/templates/plugin.go.tmpl create mode 100644 tools/plugin-dev/templates/plugin.json.tmpl create mode 100644 tools/plugin-dev/testharness/harness.go diff --git a/.gitignore b/.gitignore index a8051bd..b82b1f8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ build/ plugins/*/plugin.so .tmp-plugins/ -third_party/homeagent-sdk/.git/ +qq diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 2533085..0000000 --- a/DESIGN.md +++ /dev/null @@ -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 向量索引,不与记忆耦合 diff --git a/MILESTONE.md b/MILESTONE.md deleted file mode 100644 index 448ed68..0000000 --- a/MILESTONE.md +++ /dev/null @@ -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 — 定时器 diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 25c455e..0000000 --- a/PLAN.md +++ /dev/null @@ -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 工具│ │ -│ └──────────┘ └──────────┘ └────────────┘ └──────────────┘ │ -├─────────────────────────────────────────────────────────────────────┤ -│ 动态插件 (/plugins// 按需加载) │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ 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//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)` | -| **动态加载** | 第三方插件 | 扫描 `/plugins//`, 读 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 加载 - -插件目录结构: -``` -/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_` | 插件独立配置 | 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` | `/plugins` | 插件安装目录 | -| `core.memory.graph` | `/memory/graph.db` | 图数据库 | -| `core.memory.text` | `/memory/text` | 文本记忆目录 | -| `core.memory.documents` | `/memory/documents` | 文档记忆目录 | -| `core.knowledge.path` | `/knowledge` | 知识库目录 | -| `core.skills.path` | `/skills` | 技能目录 | -| `core.log.path` | `/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). diff --git a/docs/PLUGIN_DEV.md b/docs/PLUGIN_DEV.md index 6c90e03..19306a2 100644 --- a/docs/PLUGIN_DEV.md +++ b/docs/PLUGIN_DEV.md @@ -10,7 +10,7 @@ SDK 仓库。 ```bash git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git 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 git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git 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` 分发包: ```bash -hack/plugin-dev/packager.sh plugins/myplugin +tools/plugin-dev/packager.sh plugins/myplugin # 输出: dist/myplugin-0.1.0.hmap ``` diff --git a/internal/onebot/client.go b/internal/onebot/client.go deleted file mode 100644 index 6dc7f76..0000000 --- a/internal/onebot/client.go +++ /dev/null @@ -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) -} diff --git a/internal/onebot/device.go b/internal/onebot/device.go deleted file mode 100644 index ad4ba13..0000000 --- a/internal/onebot/device.go +++ /dev/null @@ -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 -} diff --git a/internal/onebot/types.go b/internal/onebot/types.go deleted file mode 100644 index 0bc0e95..0000000 --- a/internal/onebot/types.go +++ /dev/null @@ -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)}} -} diff --git a/internal/onebot/types_test.go b/internal/onebot/types_test.go deleted file mode 100644 index 1a7b865..0000000 --- a/internal/onebot/types_test.go +++ /dev/null @@ -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") - } -} diff --git a/tools/deploy/deploy.sh b/tools/deploy/deploy.sh new file mode 100755 index 0000000..9cc189e --- /dev/null +++ b/tools/deploy/deploy.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# HomeAgent 部署脚本 +# 用法: cd && 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)" diff --git a/tools/deploy/homeagent.service b/tools/deploy/homeagent.service new file mode 100644 index 0000000..706e885 --- /dev/null +++ b/tools/deploy/homeagent.service @@ -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 diff --git a/tools/plugin-dev/packager.sh b/tools/plugin-dev/packager.sh new file mode 100755 index 0000000..5b9b627 --- /dev/null +++ b/tools/plugin-dev/packager.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# HomeAgent 插件打包工具 +# 将插件目录打包为 .hmap 分发包 +# 用法: ./packager.sh [输出路径] +# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap + +PLUGIN_DIR="${1:-}" +OUTPUT="${2:-}" + +if [ -z "$PLUGIN_DIR" ]; then + echo "用法: $0 [输出路径]" + 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" diff --git a/tools/plugin-dev/scaffold.sh b/tools/plugin-dev/scaffold.sh new file mode 100755 index 0000000..83f6d77 --- /dev/null +++ b/tools/plugin-dev/scaffold.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +# HomeAgent 插件脚手架生成工具 +# 用法: ./scaffold.sh [输出目录] +# 示例: ./scaffold.sh myplugin ./plugins/myplugin + +NAME="${1:-}" +OUTDIR="${2:-./plugins/$NAME}" + +if [ -z "$NAME" ]; then + echo "用法: $0 [输出目录]" + 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 工具安装" diff --git a/tools/plugin-dev/templates/Makefile.tmpl b/tools/plugin-dev/templates/Makefile.tmpl new file mode 100644 index 0000000..60fa440 --- /dev/null +++ b/tools/plugin-dev/templates/Makefile.tmpl @@ -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 diff --git a/tools/plugin-dev/templates/gitignore.tmpl b/tools/plugin-dev/templates/gitignore.tmpl new file mode 100644 index 0000000..842ab43 --- /dev/null +++ b/tools/plugin-dev/templates/gitignore.tmpl @@ -0,0 +1,2 @@ +plugin.so +*.hmap diff --git a/tools/plugin-dev/templates/plugin.go.tmpl b/tools/plugin-dev/templates/plugin.go.tmpl new file mode 100644 index 0000000..83a2dfb --- /dev/null +++ b/tools/plugin-dev/templates/plugin.go.tmpl @@ -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() {} diff --git a/tools/plugin-dev/templates/plugin.json.tmpl b/tools/plugin-dev/templates/plugin.json.tmpl new file mode 100644 index 0000000..de06aed --- /dev/null +++ b/tools/plugin-dev/templates/plugin.json.tmpl @@ -0,0 +1,10 @@ +{ + "name": "{{.Name}}", + "version": "{{.Version}}", + "description": "{{.Description}}", + "author": "{{.Author}}", + "license": "MIT", + "entry": "plugin.so", + "min_version": "1.0.0", + "tags": ["{{.Name}}"] +} diff --git a/tools/plugin-dev/testharness/harness.go b/tools/plugin-dev/testharness/harness.go new file mode 100644 index 0000000..cb9bb42 --- /dev/null +++ b/tools/plugin-dev/testharness/harness.go @@ -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 }