45 Commits

Author SHA1 Message Date
16b4a56ee8 feat(sdk): 补齐流式增量事件常量 EventReasoningDelta/EventContentDelta
外置 SDK 缺失流式 delta 事件常量——外部插件无法订阅 token 级增量。
- 常量值与内核 internal/events/bus.go 完全对齐
- 向后兼容:旧插件不订阅即无影响;聚合事件仍照常发布
2026-08-25 13:54:25 +08:00
2e6d037bb9 chore: ignore example/recoverydiag (本地调试工具,含生产路径) 2026-08-25 13:22:51 +08:00
cf77bf389e chore: bump version to v0.9.1
- Version: 0.9.0 -> 0.9.1
- CoreVersion: 0.9.0 -> 0.9.1

与主仓库 HomeAgent v0.9.1 配套发布。SDK Go 代码自 33a79de 后无变更。
2026-08-25 13:22:18 +08:00
fc876c5554 feat: 单插件重载等 PluginMgr 能力导出到外部 SDK
- meta: CORE_PLUGIN_RELOAD_ONE(48) / LIST_LOADED(49) / IS_DISABLED(50)
- sdk: PluginMgrAPI 接口(ReloadOne/ListLoadedPlugins/IsPluginDisabled) +
  PluginSDK.SetPluginMgrAPI/PluginMgr() 访问器
- plugindev 模板: dispatchPluginMgr 桥接注入,走 C ABI 48/49/50
2026-08-23 19:47:55 +08:00
5c5df9cfb9 demo(luademo): pre_action stage 展示写回(llm_text 追加标记) 2026-08-15 18:11:33 +08:00
c91739d670 fix(qq): parseIDList 兼容科学计数法存库的历史坏值
配置里 QQ 号被 WebUI 以科学计数法(2.198972886e+09)存库时,
ParseInt 解析失败导致 adminIDs 为空、老大消息不被标记。
ParseInt 失败后回退 ParseFloat, 整数值转为 int64。
2026-08-15 17:24:21 +08:00
6527a40539 fix: CABINumMin 恢复为 1 兼容旧 ABI 插件(仅缺 stage 写回) 2026-08-15 16:34:25 +08:00
392f391f68 v0.9.0: C ABI v2 stage 写回 + ABI 版本对齐核心版本号
- meta: ABI 标识版本改为字符串 semver(ABIVersion=CoreVersion="0.9.0"),
  C 层协商用派生整数 CABINum=900(major*100+minor),不再用独立数字编码
- plugindev 模板: invoke_stage 增加 result out 参数(stage 写回),
  插件在 OnInput/AfterToolcall/PostAction 修改 StageContext 后回传内核
- cmd_init: CABIVersion 改用 CABINum
- example/sanitizer: 增强为全链路清洗(坏 UTF-8/U+FFFD/ANSI 转义),
  挂载 OnInput/AfterToolcall/PostAction 三阶段(依赖 stage 写回能力)
2026-08-15 15:52:10 +08:00
cca9fdce9c example: 新增 acp(ACP 代理通信)、vanblog(VanBlog 博客管理) 插件; 多插件工具调用检测与清理改进; weather 移除 onRemove/文本记忆; plugindev 精简 2026-08-15 09:03:05 +08:00
b6e30f9279 tools: plugindev 工具链支持公共 IOInjector.InjectInputSync(CORE_INJECT_INPUT_SYNC=47,z_bridge dispatchIO 桥接),重建 bin 预编译二进制;example/memo: plg.json 修复(name_en 去斜杠、去 BOM);sdk/plugin.go 注释精简 2026-08-02 16:24:59 +08:00
8e5610c494 example/memo: 待办与备忘分离(待办提醒、备忘纯记事)
- 待办(todo_add/todo_complete/todo_list):保留原有主动提醒能力——
  stagePreAction 注入未完成条数 + periodicCheck 每 5 分钟 InjectInterruptText;
  数据文件 todos.json
- 备忘(memo_create/memo_list/memo_delete):纯记事用途,不参与任何提醒
  (无 stage 注入、无周期中断);数据文件 memos.json
- cleanupData 卸载时清理两个数据文件;ID 各自独立递增
2026-08-02 15:47:37 +08:00
1796395668 sdk: 公共 IOInjector 补 InjectInputSync(同步注入并等待回复)
与主仓 third_party/homeagent-sdk 对齐:IOInjector 接口新增
InjectInputSync(source, channel, text) string + PluginSDK 便捷方法
(通道插件请求-响应流:转发入站消息并取回 agent 回复文本)
2026-08-02 15:30:22 +08:00
f3d87ec35f docs: 生命周期文档补全 onRemove(删除清理)说明
- README.md/README_EN.md:新增删除清理(onRemove)小节——语义(仅卸载
  触发、重载/禁用不触发,Stop 之后执行)、内核配套清理(工具注册/disabled/
  配置项定义 plugin.<name>.*/配置表 config_<name>)、示例清单与代码片段
- plugindev 模板 README.md.tmpl:新增 Lifecycle 段(RegisterStopHandler 每次
  停止、RegisterOnRemoveHandler 仅卸载)
2026-08-02 15:23:22 +08:00
aee63a4f98 example: 演示插件 onRemove 清理补齐(memo/rss/weather)
- memo: 卸载时删除 memos.json 数据文件
- rss: 卸载时清理 .homeagent/rss 订阅数据目录
- weather: 卸载时清理 .homeagent/weather 缓存目录
- 与 calendar(events.json)一致:仅删除触发、重载不触发;
  files(filesDir 为用户配置的访问根目录)、bili/qq(用户下载资产)、
  ocr(临时目录函数内自清理)按语义不加入删除回调
2026-08-02 13:37:59 +08:00
fb07081929 插件删除回调(onRemove)与模板/示例同步
- sdk: RegisterOnRemoveHandler/RunOnRemoveHandlers(仅卸载时触发,重载不触发;
  后注册先执行、幂等),与 RegisterStopHandler/RunStopHandlers 并存
- plugindev: 模板 main.go.tmpl 新增 onRemove 演示(删配置键),templates.go 同步
- example/calendar: RegisterOnRemoveHandler(p.cleanupData) 卸载时清理 events.json
- README/README_EN: 生命周期文档补充 onRemove
- example/calendar/plg.json: 版本对齐
2026-08-02 13:33:25 +08:00
db5d3133ea docs: document pre-built plugindev binaries in bin/ 2026-07-31 13:17:19 +08:00
12a8e99892 plugindev: rebuild pre-built binaries with latest toolchain (no local replace, auto go mod download) 2026-07-31 13:16:13 +08:00
62447e3952 plugindev: generate go.mod without local absolute replace; auto-download SDK module on first build
- init 生成的 go.mod 只 require SDK 线上版本(gitcode.com/JianFeeeee/homeagent-sdk v0.8.0),不再写本地绝对路径 replace
- build 仅在显式指定(plg.json sdk_path 或 --sdk-path)时写入 replace
- 首次构建自动执行 go mod download <sdk_module> 生成 go.sum(修复无 go.sum 构建失败)
- 修正 ensureGoMod 模块名解析(支持 require 行与 require 块)
2026-07-31 13:12:48 +08:00
bc1a005885 docs: fix plugindev install endpoint and build output; examples table; fix NewPluginFactory in bridge template
- README 构建与安装:输出目录为 dist/,安装端点为 pluginmgr POST /plugins(JSON 或 raw body),删除不存在的 /api/plugins/install
- 示例表:weather/luademo 新增、qq 更新 17 工具(补入 2b54814 未提交部分)
- templates.go: go_init_plugin 调用 NewPluginFactory(模板插件仅导出该函数)
2026-07-31 13:00:09 +08:00
2b54814037 examples: update weather with v0.8.0 API surface, add luademo Lua example, fix go.mod replaces 2026-07-31 10:59:13 +08:00
429fe9e1b9 plugindev: fall back to git clone when SDK archive download unavailable 2026-07-31 10:32:46 +08:00
87136057b1 plugindev: align Lua SDK mock with external plugin surface, refactor debug 2026-07-31 10:28:08 +08:00
84bf100a12 example/qq: add typing indicator on private messages
- setInputStatus helper wrapping NapCat set_input_status API
- startTyping/stopTyping with goroutine-based typing manager
  (5s interval, 30s auto-timeout)
- start typing on webhook private message receipt
- stop typing when output channel sends a response
- fix plg.json UTF-8 BOM that broke plugindev parse
2026-07-30 09:24:20 +08:00
78ef7998c2 docs: add RegisterInputChannel, ChannelDef to SDK README 2026-07-29 15:51:32 +08:00
b166697dd7 bump version to v0.8.0 2026-07-29 15:45:33 +08:00
4d01e75282 example/qq: use relative replace path, disable bundle mode 2026-07-29 15:24:43 +08:00
c5bcae9404 sdk: add RegisterInputChannel + NoMemory/Cleaner support
- ChannelDef struct: NoMemory bool + Cleaner func(string) string
- InputChannelRegistrar interface + RegisterInputChannel method
- RegisterOutputChannel now takes ChannelDef parameter
- QQ example registers input channel with NoMemory:true + Cleaner
2026-07-29 14:46:15 +08:00
c7c66b8d39 docs: 修正 plugindev 工具链描述 + 补充入口函数/Lua 插件说明 + 补全示例插件列表 2026-07-28 21:56:54 +08:00
04837f237d plugindev: 全面 plg.json 持久化 + --replace 支持 + 文档 2026-07-25 15:47:49 +08:00
1fa3c843ab refactor: centralize ABI metadata into meta/meta.go
- meta/meta.go: add ABIVersion, ABIVersionMin, 45 dispatch method IDs
- tools/plugindev/cmd_init.go: cabiVersion -> meta.ABIVersion
- tools/plugindev/templates.go: C header comments reference meta.go
2026-07-25 14:42:32 +08:00
cb7999ca71 feat: sdk NoMemory/Cleaner + example build fixes
- sdk/plugin.go: ToolDef adds NoMemory/Cleaner fields
- sdk/plugin_test.go: unit tests for NoMemory/Cleaner
- all example plugins: NoMemory/Cleaner annotated for each tool
- plugindev/templates.go: template shows NoMemory/Cleaner pattern
- plugindev/cmd_build.go: fix ensureGoMod, buildBundle/buildTarget sdkPath param, NewPluginFactory
- example/go.mod: add external dependency declarations (chromedp, gofeed)
- add main.go stubs for all example plugins
2026-07-25 11:17:21 +08:00
34f91ebd1a fix(browser): pre-allocate chromedp context, remove timeout contexts from interactive handlers
- Pre-allocate browser in handleBrowserStart with chromedp.Run(s.ctx)
  to avoid the first-Run timeout killing the browser process
- Replace all per-action context.WithTimeout with s.ctx in navigate,
  click, type, screenshot, html, scroll handlers
- Add cleanupLoop goroutine for session-level timeout safety
- This matches chromedp docs warning: first Run with timeout context
  stops the entire browser
2026-07-23 13:53:40 +08:00
5e0c8d5a40 feat(qq): download task tracking with qq_get_download_tasks, sync re-added 2026-07-23 10:41:08 +08:00
e182b89983 feat(qq): async file download, get_private_file_url, mark-as-read, get_recent_contacts 2026-07-23 10:36:49 +08:00
e030a9b9f9 refactor(qq): remove local message cache, direct NapCat query, URL-based file download 2026-07-23 10:27:55 +08:00
7705e4eb3b feat(a2a): add management tools (a2a_configure, a2a_restart, a2a_status) for dynamic config 2026-07-22 17:32:28 +08:00
ab3c0bfd2d fix(qq): use file_id instead of file field for group file download
NapCat file messages have file_id in data["file_id"], not data["file"].
data["file"] is the filename, causing get_file API to return empty.
Also pass url field to downloadFile for URL-direct fallback.
2026-07-21 15:33:41 +08:00
1a3e72e899 fix: use short config keys in RegisterDef, remove generics helpers
- qq, a2a, bili, files, rss: RegisterDef keys changed from
  namespaced (e.g. "plugin.qq.listen") to short flat keys ("listen")
- a2a, bili: Get() calls updated to match short keys
- rss: replaced readCfg generics with getSetting, added SetAutoRestart(true)
- files: already uses short key Get, only RegisterDef needed fixing
2026-07-21 13:20:13 +08:00
52dc22f86f feat: add example plugins (ai_image, calendar, music, rss, weather), fix .gitignore, move gengskill to tools/ 2026-07-21 12:45:17 +08:00
75ae2b4692 fix: napcat returns json.RawMessage to avoid double-quoting in C ABI bridge
go_invoke_tool does json.Marshal(r) on handler results. When r is a string,
the raw JSON gets wrapped in quotes and escapes, causing the core's
json.Unmarshal into map[string]interface{} to fail (displayed as map[]).

Changed return type to json.RawMessage (implements json.Marshaler, outputs
raw bytes inline). Added rawString() helper for callers that need the
string representation.
2026-07-20 17:51:36 +08:00
95dad649a8 feat: sdk install downloads from Release archive instead of git clone
- Replace git clone --branch with HTTP download from GitCode archive URL
- No git dependency needed for SDK installation
- Extracts tar.gz archive in-memory, strips top-level directory
- Fallback for 'latest' still uses git ls-remote (lightweight)
2026-07-20 11:50:55 +08:00
1bdec9507d feat: add NSIS Toolchain installer (plugindev + SDK auto-download) 2026-07-19 23:48:30 +08:00
1834a66bff feat: plugindev build --bundle for multi-platform .hmap
- resolveBuild: use plugin.dylib for darwin (was plugin.so)
- --bundle flag: build linux/amd64 + darwin/amd64 + windows/amd64,
  package all binaries into a single .hmap with platforms field
- writePluginJSON: accept platforms []string for bundle manifest
- add createBundleHmap for zip entries with custom names
- add PlgConfig.IsLua() helper
2026-07-19 12:05:42 +08:00
348a1447c8 feat: add package/build.sh cross-platform build script for plugindev
- Supports linux/amd64, linux/arm64, darwin/amd64, darwin/arm64,
  windows/amd64, and 'all' for matrix build
2026-07-19 11:40:21 +08:00
4f9e064721 fix: read SDK version from meta/meta.go for plugin go.mod
- detectSDKInfo now returns SDK version from meta/meta.go
- Generated plugin go.mod uses real version (v0.7.1) instead of v0.0.0
- Tag v0.7.1 created for SDK version management
2026-07-19 11:17:42 +08:00
144 changed files with 24492 additions and 1384 deletions

23
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Build artifacts
*.so
*.dll
*.o
*.exe
*.hmap
plugin.json
@ -8,15 +10,24 @@ plugin.json
build/
dist/
# Binaries
*.exe
# Test artifacts
testdist/
# Logs
*.logz_bridge_gen.go\nz_entry.c\nbuild/\ndist/
*.log
# Generated bridge files
z_bridge_gen.go
z_entry.c
build/
dist/
# Binaries (except pre-built distributions in bin/)
/plugindev
*_debug*
# Pre-built plugindev binaries in bin/ should be tracked
!bin/plugindev*
!bin/*.exe
# plugindev binary in tools/
tools/plugindev/plugindev
example/recoverydiag/

509
README.md
View File

@ -23,7 +23,8 @@ type Plugin interface {
| 分类 | 方法 | 说明 |
|------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输通道,caps 为能力位掩码 |
| 输通道 | `RegisterInputChannel(name, def)` | 注册输通道,def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道def 为 `ChannelDef`caps 为能力位掩码 |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) |
@ -47,10 +48,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
```
### ChannelDef
```go
type ChannelDef struct {
NoMemory bool // 通道输入/输出不参与记忆计算(向量/关键词/蒸馏),原文保留
Cleaner func(string) string // 可选:计算层过滤函数(不改原文)
}
```
`ChannelDef` 控制通道在记忆计算层的行为,与 `ToolDef``NoMemory`/`Cleaner` 语义一致。
### 输入通道
```go
sdk.RegisterInputChannel("qq", ChannelDef{
NoMemory: true,
Cleaner: func(text string) string { return strings.TrimSpace(text) },
})
```
### 输出通道
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler)
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
```
handler 接收三个参数:
@ -95,6 +116,20 @@ Triple 数据结构新增字段:
- `SubjectType` — 主体类型
- `ObjectType` — 客体类型
### ToolDef 字段说明
`RegisterTool``def` 参数类型为 `sdk.ToolDef`,包含以下字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `Name` | `string` | 工具名,建议插件名前缀避免冲突 |
| `Description` | `string` | 工具描述LLM 据此选择调用 |
| `Parameters` | `map[string]interface{}` | JSON Schema 格式参数定义 |
| `NoMemory` | `bool` | 默认为 `false`;设为 `true` 时输出不参与向量/jieba/蒸馏计算(原文保留) |
| `Cleaner` | `func(string) string` | 可选,输出进入计算层前的清洗函数(如 JSON 提取 `.content` |
`NoMemory``Cleaner` 的详细设计意图参见核心仓 `docs/zh/PLUGIN_DEV.md`
### New 构造函数
`New()` 由内核在加载插件时调用,插件开发者无需手动构造 PluginSDK
@ -103,35 +138,80 @@ Triple 数据结构新增字段:
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
```
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
## plugindev 工具链
`plugindev` 提供插件开发全流程支持:
`plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制linux/darwin/windows × amd64/arm64下载后直接加入 PATH 即可
```bash
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64
chmod +x plugindev
```
| 命令 | 说明 |
|------|------|
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板 |
| `plugindev build` | 构建插件,输出 .hmap 包 |
| `plugindev clean` | 清理构建产物 |
| `plugindev debug` | 本地调试模式运行插件 |
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md |
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
| `plugindev clean` | 清理 `build/``dist/` 目录及生成文件plugin.json、z_bridge_gen.go |
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
| `plugindev sdk <command>` | SDK 版本管理子命令list/install/use/path/current/latest |
支持 **Go****Lua** 两种插件语言。
### build 命令 flags
| Flag | 说明 |
|------|------|
| `--outdir <dir>` | 输出目录(默认 `dist`,可覆盖 plg.json 中的 `outdir` |
| `--target <os/arch>` | 构建目标(如 `linux/amd64`),可重复指定(追加到 plg.json 中的 targets |
| `--bundle` | 强制 bundle 模式(同时编译 linux/amd64, darwin/amd64, windows/amd64 |
| `--no-bundle` | 关闭 bundle 模式,仅按 targets 逐个编译 |
| `--sdk-path <path>` | 指定 SDK 源码路径(覆盖 plg.json 中的 `sdk_path` |
| `--replace <from=to>` / `-R` | Go 模块替换(追加到 plg.json 中的 replaces`from` 为模块路径,`to` 为本地路径 |
### plg.json 清单格式
```json
{
"name": "my-plugin",
"name": "weather",
"name_zh": "天气查询",
"name_en": "Weather",
"version": "1.0.0",
"lang": "go",
"entry": "main.go",
"description": "插件描述",
"channels": ["my-channel"],
"dependencies": {}
"description": "天气查询插件",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {
"github.com/example/pkg": "../local/pkg"
},
"source_dirs": [
"../shared-lib"
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | 插件标识名 |
| `name_zh` | string | 中文名 |
| `name_en` | string | 英文名 |
| `version` | string | 版本号 |
| `description` | string | 插件描述 |
| `author` | string | 作者 |
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua` |
| `tags` | string[] | 标签 |
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` |
| `outdir` | string | 输出目录(默认 `dist` |
| `bundle` | bool | 是否 bundle 模式(同时编译多平台,默认 `true` |
| `sdk_path` | string | SDK 源码路径(覆盖自动检测的 SDK 路径) |
| `go_version` | string | Go 版本(如 `1.21`,默认从 SDK 的 go.mod 读取) |
| `replaces` | object | Go 模块替换key=模块路径value=本地路径 |
| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入,用于引入 `thirdpart/` 外部的共享代码) |
### .hmap 包格式
`.hmap` 为 ZIP 归档,包含:
@ -139,14 +219,53 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
- `plugin.json` — 插件元数据
- `plugin.so` — Go 编译产物Linux
- `plugin.dll` — Go 编译产物Windows
- `plugin.dylib` — Go 编译产物macOSbundle 模式)
- `main.lua` — Lua 插件入口Lua 插件时)
## 插件生命周期
### 入口函数
插件必须导出 `NewPluginFactory` 入口函数Go`start()` 函数Lua
**Go 插件** — 实现 `Plugin` 接口并导出工厂函数:
```go
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
```
该函数由内核在加载插件时调用,`name` 为插件名,`config``skill.json` 中的配置(如有)。
**Lua 插件** — 返回包含 `start(sdk)``stop()` 方法的 table
```lua
local plugin = { name = "my-plugin" }
function plugin.start(sdk) -- 注册工具等 end
function plugin.stop() end
return plugin
```
### 启动与停止
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
- `Stop() error` — 插件停止,释放资源
- `sdk.RegisterStopHandler(fn func())` — 注册停止清理回调。内核(内置插件)或 z_bridge外部插件会在调用插件 `Stop()` **之前**统一执行已注册的 handler后注册先执行执行后清空、幂等。适合做持久化落盘、取消后台任务等清理此时插件内存状态仍然新鲜避免在 `Stop()` 阶段以陈旧状态写回导致数据复活。
### 删除清理onRemove
`Stop`/`RegisterStopHandler` 在插件**停止**(含重载、禁用)时执行;`RegisterOnRemoveHandler` 仅在插件被**卸载(删除)**时执行一次,重载/禁用不触发:
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
- 示例:`example/calendar`(删 events.json`example/memo`(删 memos.json`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
```go
sdk.RegisterOnRemoveHandler(func() {
os.Remove(filepath.Join(dataDir, "events.json"))
})
```
### 自动重启
@ -171,17 +290,341 @@ enabled := sdk.AutoRestart()
## 示例插件
| 插件 | 说明 |
|------|------|
| a2a | Agent-to-Agent 协议通信 |
| bili | Bilibili 视频下载 |
| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch |
| editdoc | 文档编辑 |
| files | 文件管理 |
| memo | 备忘录/记忆 |
| ocr | 光学字符识别 |
| qq | QQ 消息集成 |
| sanitizer | 内容清洗/安全过滤 |
| 插件 | 类型 | 说明 |
|------|------|------|
| [weather](example/weather) | Go | 天气查询wttr.in演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
| [luademo](example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
| [qq](example/qq) | Go | QQ 消息集成NapCat17 个工具,输入/输出通道完整对接 |
| [a2a](example/a2a) | Go | Agent-to-Agent 协议通信 |
| [ai_image](example/ai_image) | Go | AI 图片生成 |
| [bili](example/bili) | Go | Bilibili 视频下载 |
| [browser](example/browser) | Go | 网络搜索、网页抓取、浏览器渲染 |
| [calendar](example/calendar) | Go | 日历管理 |
| [editdoc](example/editdoc) | Go | 文档编辑 |
| [files](example/files) | Go | 文件管理 |
| [memo](example/memo) | Go | 备忘录PreAction 注入 + 定时提醒) |
| [music](example/music) | Go | 音乐播放 |
| [ocr](example/ocr) | Go | 光学字符识别 |
| [rss](example/rss) | Go | RSS 订阅 |
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
## Remote Device SDK
用于开发**远程设备接入适配器**的 C 语言 SDK零外部依赖兼容嵌入式平台。
### 架构
```
┌─────────────────────────────────────────────────┐
│ ha_remotedevice (C SDK) │
│ 协议引擎 │ WS 帧 │ JSON │ 状态机 │ 传输抽象 │
└──────────┬──────────────────────────────────────┘
│ 同一份 C 代码,设备端和 App 端共用
┌──────┴──────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────────────────┐
│ ESP32 裸机 │ │ Linux 设备上的 App │
│ 纯 C 直调 │ │ (Python ctypes / Go CGo / │
│ 简单命令处理 │ │ Node addon / C# P/Invoke) │
└──────────────┘ └──────────────────────────┘
```
### 声明式 API 设计
设备在代码中声明**自己是什么**、**能做什么**、**支持哪些命令**每个命令对应独立处理函数SDK 自动分发并回执结果:
```c
#include "ha_remotedevice.h"
/* 声明能力 */
const char *caps[] = {"camera", "status", NULL};
/* 声明式命令处理表:每个命令绑定独立处理函数 */
static ha_status_t handle_camerasue(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
int duration = args[0] ? atoi(args) : 0;
// 拍照/录像...
result->status = 0;
result->output = "data:image/jpeg;base64,..."; // SDK 自动回执
return HA_OK;
}
ha_cmd_handler_def_t handlers[] = {
{.command = "shell", .handler = handle_shell},
{.command = "camerasue", .handler = handle_camerasue},
{.command = "screensee", .handler = handle_screensee},
{.command = "speakeruse", .handler = handle_speakeruse},
{.command = NULL}, /* 标记结束 */
};
ha_config_t config = {
.transport = my_transport, // 用户实现 4 个函数
.server = "192.168.1.100:9890",
.token = "my-token",
.device = {
.device_id = "esp32-cam-1",
.name = "门口摄像头",
.kind = "camera",
.caps = caps,
},
.handlers = handlers, // 声明式命令处理表
.on_state = my_state_handler,
};
ha_client_t *client = ha_client_new(&config);
ha_client_start(client);
while (1) {
ha_client_process(client); // 主循环处理
}
```
### 传输层抽象
用户只需实现 4 个函数,适配不同平台:
```c
ha_transport_t my_transport = {
.connect = my_tcp_connect, // 建立 TCP 连接
.send = my_tcp_send, // 发送数据
.recv = my_tcp_recv, // 接收数据(阻塞)
.close = my_tcp_close, // 关闭连接
.ctx = &my_platform_ctx,
};
```
### 支持的协议
| 功能 | API |
|------|-----|
| WS 连接 + 握手 | `ha_client_start` 自动完成 |
| 设备注册 (hello/bind) | 启动时自动发送 |
| 命令接收 (shell/homeagent) | `handlers` 表声明式注册SDK 自动分发 |
| 命令回执 | `ha_client_send_result` |
| 二进制分块(录像等) | `ha_client_send_data_chunked` |
| TTS 音频接收 | `on_binary` 回调 |
| 事件上报 | `ha_client_send_event` |
| 状态上报 | `ha_client_send_status` |
| 心跳保持 | 自动 ping/pong |
### 使用方式
通过 `plugindev` 工具链初始化项目:
```bash
plugindev init my-adapter --type remotedevice
```
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
```cmake
add_subdirectory(path/to/ha_remotedevice)
target_link_libraries(my_app ha_remotedevice)
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
```
### 快速接入指南
以下是从零到设备成功接入 HomeAgent 的完整步骤。
#### 1. 准备工作
在 HomeAgent 平台上创建接入令牌:
```bash
# 在 HomeAgent 服务端创建一个设备接入令牌
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
-H "Content-Type: application/json" \
-d '{"device_id":"esp32-cam-1","name":"门口摄像头","kind":"camera"}'
# 返回: {"token":"ha-dev-token-xxxxx"}
```
记录下返回的 `token`,设备端配置时使用。
#### 2. 实现传输层4 个函数)
根据你的平台实现 `ha_transport_t` 的 4 个函数指针。以下是几种常见场景:
**场景 A带 TCP/IP 栈的嵌入式设备(如 ESP32 + lwIP**
```c
#include "ha_remotedevice.h"
#include "lwip/sockets.h"
static int esp_connect(void *ctx, const char *host, uint16_t port) {
struct sockaddr_in addr;
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) return -1;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, host, &addr.sin_addr);
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
if (ret < 0) { closesocket(sock); return -1; }
*(int *)ctx = sock;
return 0;
}
static int esp_send(void *ctx, const uint8_t *data, int len) {
int sock = *(int *)ctx;
return send(sock, (const char *)data, len, 0);
}
static int esp_recv(void *ctx, uint8_t *buf, int len) {
int sock = *(int *)ctx;
return recv(sock, (char *)buf, len, 0);
}
static void esp_close(void *ctx) {
int sock = *(int *)ctx;
closesocket(sock);
}
int esp_ctx = -1;
ha_transport_t transport = {
.connect = esp_connect,
.send = esp_send,
.recv = esp_recv,
.close = esp_close,
.ctx = &esp_ctx,
};
```
**场景 B通过串口UART连接透传模块**
```c
static int uart_connect(void *ctx, const char *host, uint16_t port) {
(void)host; (void)port;
// 初始化 UART波特率 115200
return uart_init((uart_ctx_t *)ctx, 115200);
}
static int uart_send(void *ctx, const uint8_t *data, int len) {
return uart_write((uart_ctx_t *)ctx, data, len);
}
static int uart_recv(void *ctx, uint8_t *buf, int len) {
return uart_read((uart_ctx_t *)ctx, buf, len);
}
static void uart_close(void *ctx) {
uart_deinit((uart_ctx_t *)ctx);
}
```
> 注意UART 透传时,另一端需运行一个 TCP 桥接程序,将串口数据转发到 HomeAgent 的 WebSocket 端口。
#### 3. 声明设备能力和命令处理
```c
#include "ha_remotedevice.h"
/* 声明设备能力 */
const char *caps[] = {"camera", "speaker", "status", NULL};
/* 处理 camerasue 命令(拍照) */
static ha_status_t handle_camera(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
int duration = args[0] ? atoi(args) : 0; // 参数:录像时长
// 拍照或录像,将结果填入 result
result->status = 0;
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 图像数据
return HA_OK;
}
/* 处理 shell 命令 */
static ha_status_t handle_shell(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
// 执行 shell 命令args 为完整命令字符串
result->status = 0;
result->output = "command executed";
return HA_OK;
}
/* 声明式命令处理表 */
ha_cmd_handler_def_t handlers[] = {
{.command = "shell", .handler = handle_shell},
{.command = "camerasue", .handler = handle_camera},
{.command = "screensee", .handler = handle_camera},
{.command = "speakeruse", .handler = handle_speaker},
{.command = NULL}, /* 标记结束 */
};
```
#### 4. 配置并启动客户端
```c
ha_config_t config = {
.transport = transport, // 传输层实现
.server = "192.168.1.100:9890", // HomeAgent 服务端地址
.token = "ha-dev-token-xxxxx", // 第 1 步获取的令牌
.device = {
.device_id = "esp32-cam-1",
.name = "门口摄像头",
.kind = "camera",
.caps = caps,
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
},
.handlers = handlers, // 命令处理表
.on_binary = on_binary_data, // 接收 TTS 音频等二进制数据
.on_state = on_state_change, // 连接状态变化回调
.ping_interval = 30, // 心跳间隔秒数
};
ha_client_t *client = ha_client_new(&config);
ha_status_t ret = ha_client_start(client);
if (ret != HA_OK) {
printf("设备接入失败: %d\n", ret);
return;
}
/* 主循环 */
while (1) {
ha_client_process(client); // 处理协议帧、心跳、命令分发
/* 可选:设备主动上报事件 */
ha_client_send_event(client, "motion_detected",
"{\"zone\":\"front_door\",\"confidence\":0.95}");
/* 可选:上报设备状态 */
ha_client_send_status(client, "online");
vTaskDelay(100 / portTICK_PERIOD_MS); // 嵌入式 RTOS 风格延时
}
```
#### 5. 验证连接
在 HomeAgent 服务端检查设备是否在线:
```bash
# 查看已注册设备列表
curl http://<homeagent-server>:8080/api/v1/device/list
# 预期输出包含: {"device_id":"esp32-cam-1","status":"online",...}
# 向设备发送命令(测试 camerasue
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
-H "Content-Type: application/json" \
-d '{"cmd":"camerasue","args":"3"}'
# 预期返回: {"status":"ok","result":"data:image/jpeg;base64,..."}
```
#### 6. 调试技巧
| 问题 | 检查点 |
|------|--------|
| 连接失败 | 确认 `server` 地址和端口可通;检查 `token` 是否正确 |
| WS 握手失败 | 确认 HomeAgent 服务端已开启 WebSocket 支持 |
| 命令无响应 | 确认 `handlers` 表中注册了对应命令名;检查 `on_binary` 是否配置 |
| 断线重连 | `max_reconnect` 控制重连次数,-1 为无限重连 |
| 内存不足(嵌入式) | 定义 `HA_NO_ALLOC` 宏禁用动态内存分配 |
### 位置
- **SDK 源码**: `remotedevice/`
- **plugindev 模板**: `plugindev init --type remotedevice`
## 构建与安装
@ -191,15 +634,21 @@ enabled := sdk.AutoRestart()
plugindev build
```
输出 `.hmap` 包到项目目录
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`
### 安装
通过 pluginmgr HTTP API 安装:
通过 pluginmgr HTTP API 安装(端口默认 9876仅监听 127.0.0.1,无鉴权)
```bash
curl -X POST http://<host>:<port>/api/plugins/install \
-F "package=@my-plugin.hmap"
# 本地路径
curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/my-plugin.hmap"}'
# 直接上传二进制
curl -X POST http://127.0.0.1:9876/plugins \
--data-binary @dist/my-plugin.hmap
```
或手动将 `.hmap` 放入插件目录后重启平台。
通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。

View File

@ -23,7 +23,8 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
| Category | Method | Description |
|----------|--------|-------------|
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask |
| Input Channel | `RegisterInputChannel(name, def)` | Register input channel with `ChannelDef` (NoMemory/Cleaner) |
| Output Channel | `RegisterOutputChannel(name, caps, desc, def, handler)` | Register output channel with `ChannelDef` and capability bitmask |
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
@ -47,10 +48,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
```
### ChannelDef
```go
type ChannelDef struct {
NoMemory bool // Channel input/output skips memory computation (vector/keyword/distill), original text preserved
Cleaner func(string) string // Optional: computation layer filter (does not modify original text)
}
```
`ChannelDef` controls channel behavior in the memory computation layer, with the same semantics as `ToolDef.NoMemory`/`Cleaner`.
### Input Channels
```go
sdk.RegisterInputChannel("qq", ChannelDef{
NoMemory: true,
Cleaner: func(text string) string { return strings.TrimSpace(text) },
})
```
### Output Channels
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler)
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", ChannelDef{}, handler)
```
The handler receives three arguments:
@ -95,6 +116,20 @@ The Triple data structure includes additional fields:
- `SubjectType` — subject type
- `ObjectType` — object type
### ToolDef Field Reference
The `def` parameter of `RegisterTool` is of type `sdk.ToolDef`, with the following fields:
| Field | Type | Description |
|-------|------|-------------|
| `Name` | `string` | Tool name, use plugin name prefix to avoid conflicts |
| `Description` | `string` | Tool description, LLM uses this for tool selection |
| `Parameters` | `map[string]interface{}` | JSON Schema parameter definition |
| `NoMemory` | `bool` | Default `false`; when `true`, output skips vector/jieba/distill computation (original text preserved) |
| `Cleaner` | `func(string) string` | Optional, filters output before computation layer (e.g., extract `.content` from JSON) |
For detailed design rationale of `NoMemory` and `Cleaner`, see `docs/en/PLUGIN_DEV.md` in the core repository.
### New Constructor
`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually:
@ -122,16 +157,42 @@ Supports both **Go** and **Lua** plugin languages.
```json
{
"name": "my-plugin",
"name": "weather",
"name_zh": "天气查询",
"name_en": "Weather",
"version": "1.0.0",
"lang": "go",
"entry": "main.go",
"description": "Plugin description",
"channels": ["my-channel"],
"dependencies": {}
"description": "Weather plugin",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {
"github.com/example/pkg": "../local/pkg"
},
"source_dirs": [
"../shared-lib"
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Plugin identifier |
| `name_zh` | string | Chinese name |
| `name_en` | string | English name |
| `version` | string | Version |
| `description` | string | Plugin description |
| `author` | string | Author |
| `entry` | string | Entry file (`plugin.so` / `main.lua`) |
| `tags` | string[] | Tags |
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
| `outdir` | string | Output directory (default `dist`) |
| `bundle` | bool | Bundle mode (build all platforms at once) |
| `replaces` | object | Go module replacements, key=module path, value=local path |
| `source_dirs` | string[] | Additional source search paths (auto-imported at build time) |
### .hmap Package Format
`.hmap` is a ZIP archive containing:
@ -147,6 +208,21 @@ Supports both **Go** and **Lua** plugin languages.
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
- `Stop() error` — Plugin shutdown, release resources
- `sdk.RegisterStopHandler(fn func())` — Register a shutdown cleanup callback. The kernel (for built-in plugins) or z_bridge (for external plugins) runs all registered handlers **before** calling the plugin's `Stop()` (LIFO order, cleared after running — idempotent). Use it for persistence and cancelling background work: plugin memory is still fresh at that point, avoiding stale-state write-backs that resurrect deleted data.
### Remove Cleanup (onRemove)
`Stop` / `RegisterStopHandler` run whenever the plugin **stops** (including reload and disable); `RegisterOnRemoveHandler` runs **only once when the plugin is uninstalled (removed)** — never on reload or disable:
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
```go
sdk.RegisterOnRemoveHandler(func() {
os.Remove(filepath.Join(dataDir, "events.json"))
})
```
### Auto-Restart
@ -171,18 +247,322 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
## Example Plugins
| Plugin | Description |
|--------|-------------|
| a2a | Agent-to-Agent protocol communication |
| bili | Bilibili data fetching |
| editdoc | Document editing |
| files | File management |
| memo | Memo/notes |
| ocr | Optical character recognition |
| qq | QQ messaging integration |
| sanitizer | Content sanitization/safety filtering |
| web | Web browsing and interaction |
| webfetch | Web content fetching |
| Plugin | Type | Description |
|--------|------|-------------|
| [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
| [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
| [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
| [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
| [ai_image](example/ai_image) | Go | AI image generation |
| [bili](example/bili) | Go | Bilibili video downloading |
| [browser](example/browser) | Go | Web search, page fetching, browser rendering |
| [calendar](example/calendar) | Go | Calendar management |
| [editdoc](example/editdoc) | Go | Document editing |
| [files](example/files) | Go | File management |
| [memo](example/memo) | Go | Memos (PreAction injection + scheduled reminders) |
| [music](example/music) | Go | Music playback |
| [ocr](example/ocr) | Go | Optical character recognition |
| [rss](example/rss) | Go | RSS subscriptions |
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
## Remote Device SDK
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
### Architecture
```
┌─────────────────────────────────────────────────┐
│ ha_remotedevice (C SDK) │
│ Protocol Engine │ WS Frames │ JSON │ State │
│ Machine │ Transport Abstraction │
└──────────┬──────────────────────────────────────┘
│ Same C code, shared by device & app
┌──────┴──────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────────────────┐
│ ESP32 Bare │ │ Linux App │
│ Pure C │ │ (Python ctypes / Go CGo /│
│ Simple Cmd │ │ Node addon / C# P/Invoke)│
└──────────────┘ └──────────────────────────┘
```
### Declarative API Design
The device declares **what it is** and **what it can do** in code. The SDK handles all protocol details automatically:
```c
#include "ha_remotedevice.h"
/* Declare capabilities */
const char *caps[] = {"camera", "status", NULL};
ha_config_t config = {
.transport = my_transport, // User implements 4 functions
.server = "192.168.1.100:9890",
.token = "my-token",
.device = {
.device_id = "esp32-cam-1",
.name = "Front Door Camera",
.kind = "camera",
.caps = caps,
},
.on_cmd = my_cmd_handler, // Called when receiving commands
.on_binary = my_data_handler, // Called on binary data (TTS audio, etc.)
.on_state = my_state_handler, // Connection state changes
};
ha_client_t *client = ha_client_new(&config);
ha_client_start(client);
while (1) {
ha_client_process(client); // Main loop processing
}
```
### Transport Layer Abstraction
Users only need to implement 4 functions to adapt to different platforms:
```c
ha_transport_t my_transport = {
.connect = my_tcp_connect, // Establish TCP connection
.send = my_tcp_send, // Send data
.recv = my_tcp_recv, // Receive data (blocking)
.close = my_tcp_close, // Close connection
.ctx = &my_platform_ctx,
};
```
### Protocol Support
| Feature | API |
|---------|-----|
| WS connection + handshake | Automatic via `ha_client_start` |
| Device registration (hello/bind) | Automatic on startup |
| Command receive (shell/homeagent) | `on_cmd` callback |
| Command result | `ha_client_send_result` |
| Binary chunked transfer (video) | `ha_client_send_data_chunked` |
| TTS audio receive | `on_binary` callback |
| Event reporting | `ha_client_send_event` |
| Status reporting | `ha_client_send_status` |
| Heartbeat keepalive | Automatic ping/pong |
### Usage
Initialize a project via the `plugindev` toolchain:
```bash
plugindev init my-adapter --type remotedevice
```
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
```cmake
add_subdirectory(path/to/ha_remotedevice)
target_link_libraries(my_app ha_remotedevice)
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
```
### Quick Start Guide
A complete step-by-step guide from zero to a device successfully connected to HomeAgent.
#### Step 1: Preparation
Create an access token on the HomeAgent platform:
```bash
# Create a device access token on the HomeAgent server
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
-H "Content-Type: application/json" \
-d '{"device_id":"esp32-cam-1","name":"Front Door Camera","kind":"camera"}'
# Returns: {"token":"ha-dev-token-xxxxx"}
```
Save the returned `token` — you'll need it in the device configuration.
#### Step 2: Implement the Transport Layer (4 functions)
Implement the 4 function pointers of `ha_transport_t` for your platform. Here are common scenarios:
**Scenario A: Embedded device with TCP/IP stack (e.g., ESP32 + lwIP)**
```c
#include "ha_remotedevice.h"
#include "lwip/sockets.h"
static int esp_connect(void *ctx, const char *host, uint16_t port) {
struct sockaddr_in addr;
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) return -1;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, host, &addr.sin_addr);
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
if (ret < 0) { closesocket(sock); return -1; }
*(int *)ctx = sock;
return 0;
}
static int esp_send(void *ctx, const uint8_t *data, int len) {
int sock = *(int *)ctx;
return send(sock, (const char *)data, len, 0);
}
static int esp_recv(void *ctx, uint8_t *buf, int len) {
int sock = *(int *)ctx;
return recv(sock, (char *)buf, len, 0);
}
static void esp_close(void *ctx) {
int sock = *(int *)ctx;
closesocket(sock);
}
int esp_ctx = -1;
ha_transport_t transport = {
.connect = esp_connect,
.send = esp_send,
.recv = esp_recv,
.close = esp_close,
.ctx = &esp_ctx,
};
```
**Scenario B: Serial (UART) passthrough module**
```c
static int uart_connect(void *ctx, const char *host, uint16_t port) {
(void)host; (void)port;
return uart_init((uart_ctx_t *)ctx, 115200);
}
static int uart_send(void *ctx, const uint8_t *data, int len) {
return uart_write((uart_ctx_t *)ctx, data, len);
}
static int uart_recv(void *ctx, uint8_t *buf, int len) {
return uart_read((uart_ctx_t *)ctx, buf, len);
}
static void uart_close(void *ctx) {
uart_deinit((uart_ctx_t *)ctx);
}
```
> Note: For UART passthrough, a TCP bridge program must run on the other end to forward serial data to the HomeAgent WebSocket port.
#### Step 3: Declare Device Capabilities and Command Handlers
```c
#include "ha_remotedevice.h"
/* Declare device capabilities */
const char *caps[] = {"camera", "speaker", "status", NULL};
/* Handle camerasue command (take photo) */
static ha_status_t handle_camera(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
int duration = args[0] ? atoi(args) : 0;
// Capture image, fill the result
result->status = 0;
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 image data
return HA_OK;
}
/* Handle shell command */
static ha_status_t handle_shell(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata) {
(void)req_id; (void)userdata;
result->status = 0;
result->output = "command executed";
return HA_OK;
}
/* Declarative command handler table */
ha_cmd_handler_def_t handlers[] = {
{.command = "shell", .handler = handle_shell},
{.command = "camerasue", .handler = handle_camera},
{.command = "screensee", .handler = handle_camera},
{.command = "speakeruse", .handler = handle_speaker},
{.command = NULL}, /* terminator */
};
```
#### Step 4: Configure and Start the Client
```c
ha_config_t config = {
.transport = transport, // Transport layer implementation
.server = "192.168.1.100:9890", // HomeAgent server address
.token = "ha-dev-token-xxxxx", // Token from Step 1
.device = {
.device_id = "esp32-cam-1",
.name = "Front Door Camera",
.kind = "camera",
.caps = caps,
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
},
.handlers = handlers, // Command handler table
.on_binary = on_binary_data, // Receive TTS audio etc.
.on_state = on_state_change, // Connection state callback
.ping_interval = 30,
};
ha_client_t *client = ha_client_new(&config);
ha_status_t ret = ha_client_start(client);
if (ret != HA_OK) {
printf("Device connection failed: %d\n", ret);
return;
}
/* Main loop */
while (1) {
ha_client_process(client); // Process protocol frames, heartbeats, commands
/* Optional: device-initiated event reporting */
ha_client_send_event(client, "motion_detected",
"{\"zone\":\"front_door\",\"confidence\":0.95}");
/* Optional: report device status */
ha_client_send_status(client, "online");
vTaskDelay(100 / portTICK_PERIOD_MS); // RTOS-style delay
}
```
#### Step 5: Verify the Connection
Check if the device is online on the HomeAgent server:
```bash
# List registered devices
curl http://<homeagent-server>:8080/api/v1/device/list
# Expected output includes: {"device_id":"esp32-cam-1","status":"online",...}
# Send a command to the device (test camerasue)
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
-H "Content-Type: application/json" \
-d '{"cmd":"camerasue","args":"3"}'
# Expected: {"status":"ok","result":"data:image/jpeg;base64,..."}
```
#### Step 6: Debugging Tips
| Issue | Check |
|-------|-------|
| Connection failed | Verify `server` address and port are reachable; check `token` |
| WS handshake failed | Verify HomeAgent server WebSocket support is enabled |
| Command not responding | Confirm the command name is registered in `handlers` table; check `on_binary` |
| Reconnection issues | `max_reconnect` controls retry count; -1 = infinite |
| Low memory (embedded) | Define `HA_NO_ALLOC` to disable dynamic memory allocation |
### Location
- **SDK Source**: `remotedevice/`
- **plugindev template**: `plugindev init --type remotedevice`
## Building & Installing
@ -192,15 +572,21 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
plugindev build
```
Outputs a `.hmap` package to the project directory.
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
### Install
Via pluginmgr HTTP API:
Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
```bash
curl -X POST http://<host>:<port>/api/plugins/install \
-F "package=@my-plugin.hmap"
# Local path
curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/my-plugin.hmap"}'
# Upload binary directly
curl -X POST http://127.0.0.1:9876/plugins \
--data-binary @dist/my-plugin.hmap
```
Or manually place the `.hmap` in the plugin directory and restart the platform.
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.

BIN
bin/plugindev_darwin_amd64 Executable file

Binary file not shown.

BIN
bin/plugindev_darwin_arm64 Executable file

Binary file not shown.

BIN
bin/plugindev_linux_amd64 Executable file

Binary file not shown.

BIN
bin/plugindev_linux_arm64 Executable file

Binary file not shown.

BIN
bin/plugindev_windows_amd64.exe Executable file

Binary file not shown.

View File

@ -2,6 +2,6 @@ module a2a
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

2
example/a2a/go.sum Normal file
View File

@ -0,0 +1,2 @@
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=

View File

@ -3,7 +3,7 @@
package main
import (
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -7,5 +7,9 @@
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["a2a", "agent", "interop"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -9,15 +9,18 @@ import (
"net"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
server *http.Server
name string
sdk *sdk.PluginSDK
srvMu sync.Mutex
server *http.Server
serverAddr string
}
func (p *Plugin) Name() string { return p.name }
@ -28,7 +31,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
tp := p.name + "_"
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin." + p.name + ".listen", Default: "127.0.0.1:12000",
Key: "listen", Default: "127.0.0.1:12000",
Type: "string", DisplayName: "监听地址",
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
Category: p.name,
@ -46,6 +49,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
"required": []string{"agent_url", "query"},
},
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleA2AQuery)
s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{
@ -59,10 +69,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleA2ADiscover)
// Management tools
s.RegisterTool(tp+"a2a_configure", sdk.ToolDef{
Name: tp + "a2a_configure", Description: "修改 A2A 插件配置并自动重启服务。支持动态更改监听地址等参数。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12000设为空字符串禁用 HTTP 服务)"},
},
},
}, p.handleConfigure)
s.RegisterTool(tp+"a2a_restart", sdk.ToolDef{
Name: tp + "a2a_restart", Description: "重启 A2A HTTP 服务端。当连接异常或配置变更后需要重新加载时使用。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleRestart)
s.RegisterTool(tp+"a2a_status", sdk.ToolDef{
Name: tp + "a2a_status", Description: "查看 A2A 插件的运行状态,包括监听地址和当前配置。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleStatus)
// Inbound HTTP server
if addr, _ := s.Settings().Get("plugin." + p.name + ".listen"); addr != nil {
if addr, _ := s.Settings().Get("listen"); addr != nil {
if addrStr, ok := addr.(string); ok && addrStr != "" {
p.startServer(addrStr)
if err := p.startServer(addrStr); err != nil {
log.Printf("[%s] start A2A server: %v", p.name, err)
}
}
}
@ -71,15 +110,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
func (p *Plugin) Stop() error {
p.stopServer()
return nil
}
func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
if p.server != nil {
p.server.Close()
p.server = nil
p.serverAddr = ""
}
return nil
}
// ---- Inbound HTTP Server ----
func (p *Plugin) startServer(addr string) {
func (p *Plugin) startServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/agent-card", p.handleAgentCard)
mux.HandleFunc("/task", p.handleIncomingTask)
@ -87,17 +134,27 @@ func (p *Plugin) startServer(addr string) {
listener, err := net.Listen("tcp", addr)
if err != nil {
log.Printf("[%s] listen %s: %v", p.name, addr, err)
return
return fmt.Errorf("listen %s: %v", addr, err)
}
p.server = &http.Server{Handler: mux}
srv := &http.Server{Handler: mux}
addrStr := listener.Addr().String()
p.srvMu.Lock()
if p.server != nil {
p.server.Close()
}
p.server = srv
p.serverAddr = addrStr
p.srvMu.Unlock()
go func() {
log.Printf("[%s] A2A server on %s", p.name, listener.Addr())
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] A2A server on %s", p.name, addrStr)
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] serve: %v", p.name, err)
}
}()
return nil
}
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
@ -365,6 +422,62 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
}, nil
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
// ---- Management Handlers ----
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
listen, _ := args["listen"].(string)
listen = strings.TrimSpace(listen)
if err := p.sdk.Settings().Set("listen", listen); err != nil {
return fmt.Sprintf("保存配置失败: %v", err), nil
}
if listen == "" || listen == "off" || listen == "disabled" {
p.stopServer()
return "A2A HTTP 服务已禁用listen 设为空)", nil
}
if err := p.startServer(listen); err != nil {
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
}
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
}
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
p.stopServer()
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
return "A2A 服务未配置监听地址listen 为空),无法启动", nil
}
if err := p.startServer(addrStr); err != nil {
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
}
p.srvMu.Lock()
listening := p.serverAddr
p.srvMu.Unlock()
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
}
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
p.srvMu.Lock()
serverRunning := p.server != nil
listening := p.serverAddr
p.srvMu.Unlock()
if !serverRunning {
listening = "未运行"
}
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s",
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning]), nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

7
example/acp/go.mod Normal file
View File

@ -0,0 +1,7 @@
module acp
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/acp/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

15
example/acp/plg.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "acp",
"name_zh": "ACP 代理通信",
"name_en": "ACP Agent Client Protocol",
"version": "1.0.0",
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent如 opencode发起会话并读取回复",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["acp", "agent", "interop"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

528
example/acp/plugin.go Normal file
View File

@ -0,0 +1,528 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
// - 服务端POST /api/session JSON-RPCsession/new / session/update
// 请求注入本 Agent另提供 GET /api/session?id=xxx SSE 事件流。
// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。
type Plugin struct {
name string
sdk *sdk.PluginSDK
srvMu sync.Mutex
server *http.Server
serverID string
mu sync.RWMutex
sessions map[string]*sessionState
}
type sessionState struct {
ID string
Replying []map[string]interface{}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*sessionState)
tp := p.name + "_"
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12001",
Type: "string", DisplayName: "监听地址",
Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务",
Category: p.name,
})
s.RegisterTool(tp+"acp_query", sdk.ToolDef{
Name: tp + "acp_query", Description: "向远程 ACP Agent如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001发起一个会话请求并等待回复返回其最终回答文本兼容 SSE 型与同步 JSON 型 ACP 服务端",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000"},
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
},
"required": []string{"server_url", "prompt"},
},
Cleaner: func(output string) string {
var r struct {
Reply string `json:"reply"`
}
if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" {
return r.Reply
}
return output
},
}, p.handleAcpQuery)
s.RegisterTool(tp+"acp_configure", sdk.ToolDef{
Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001设为空禁用"},
},
},
}, p.handleConfigure)
s.RegisterTool(tp+"acp_status", sdk.ToolDef{
Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleStatus)
addr, _ := s.Settings().Get("listen")
if addrStr, ok := addr.(string); ok && addrStr != "" {
if err := p.startServer(addrStr); err != nil {
log.Printf("[%s] start ACP server: %v", p.name, err)
}
}
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error {
p.stopServer()
return nil
}
func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
if p.server != nil {
p.server.Close()
p.server = nil
p.serverID = ""
}
}
// ---- Inbound HTTP Server ----
func (p *Plugin) startServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/session", p.handleSession)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %v", addr, err)
}
srv := &http.Server{Handler: mux}
addrStr := listener.Addr().String()
p.srvMu.Lock()
if p.server != nil {
p.server.Close()
}
p.server = srv
p.serverID = addrStr
p.srvMu.Unlock()
go func() {
log.Printf("[%s] ACP server on %s", p.name, addrStr)
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] serve: %v", p.name, err)
}
}()
return nil
}
func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
p.handleSessionPost(w, r)
case "GET":
p.handleSessionSSE(w, r)
default:
http.Error(w, "", http.StatusMethodNotAllowed)
}
}
// handleSessionPost 处理 JSON-RPCsession/new 与 session/update
func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Method string `json:"method"`
Params struct {
Request *struct {
Text string `json:"text"`
} `json:"request,omitempty"`
SessionID string `json:"session_id,omitempty"`
Final bool `json:"final,omitempty"`
} `json:"params,omitempty"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
return
}
switch req.Method {
case "session/new":
text := ""
if req.Params.Request != nil {
text = strings.TrimSpace(req.Params.Request.Text)
}
if text == "" {
http.Error(w, "request.text required", http.StatusBadRequest)
return
}
sid := fmt.Sprintf("session_%d", time.Now().UnixNano())
p.mu.Lock()
p.sessions[sid] = &sessionState{ID: sid}
p.mu.Unlock()
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, "acp",
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, text))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session": map[string]interface{}{"id": sid},
},
})
case "session/update":
sid := req.Params.SessionID
p.mu.Lock()
st := p.sessions[sid]
if st != nil && req.Params.Final {
st.Replying = append(st.Replying, map[string]interface{}{
"type": "reply", "text": "done",
})
}
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"final": true},
})
case "session/cancel":
p.mu.Lock()
delete(p.sessions, req.Params.SessionID)
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"canceled": true},
})
default:
http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest)
}
}
// handleSessionSSE 提供 SSE 事件流订阅
func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) {
sid := r.URL.Query().Get("id")
if sid == "" {
http.Error(w, "id query param required", http.StatusBadRequest)
return
}
p.mu.RLock()
st := p.sessions[sid]
p.mu.RUnlock()
if st == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
p.mu.RLock()
replies := append([]map[string]interface{}{}, st.Replying...)
p.mu.RUnlock()
for _, rep := range replies {
data, _ := json.Marshal(rep)
fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data)
fl.Flush()
}
p.mu.Lock()
st.Replying = nil
p.mu.Unlock()
select {
case <-r.Context().Done():
return
case <-ticker.C:
}
}
}
// ---- OutboundACP 客户端 ----
// parseRPCBody 兼容 JSON 与 SSE 两种响应体
func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) {
if strings.Contains(ct, "text/event-stream") {
sc := bufio.NewScanner(bytes.NewReader(body))
var last string
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data != "" && data != "[DONE]" {
last = data
}
}
}
if last == "" {
return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200))
}
body = []byte(last)
}
var raw json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300))
}
return &raw, nil
}
func truncateStr(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) {
serverURL, _ := args["server_url"].(string)
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
if serverURL == "" {
return map[string]interface{}{"error": "server_url 不能为空"}, nil
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "http://" + serverURL
}
prompt, _ := args["prompt"].(string)
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return map[string]interface{}{"error": "prompt 不能为空"}, nil
}
timeoutSec := 120
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v)
}
endpoint := serverURL + "/api/session"
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
newBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
"method": "session/new",
"params": map[string]interface{}{
"request": map[string]interface{}{"text": prompt},
},
})
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 {
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil
}
raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
var rpcResp struct {
Result *struct {
Session *struct {
ID string `json:"id"`
} `json:"session,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Reply string `json:"reply,omitempty"`
} `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
if err := json.Unmarshal(*raw, &rpcResp); err != nil {
return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil
}
if rpcResp.Error != nil {
return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil
}
if rpcResp.Result == nil {
return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil
}
// 兼容两种协议:
// A) 标准/SSE 型opencode、本插件服务端result.session.id回复经 SSE 事件流
// B) 同步 JSON 型pi bridgeresult.sessionId + result.reply
if rpcResp.Result.Reply != "" {
return map[string]interface{}{
"session_id": rpcResp.Result.SessionID,
"status": "completed",
"reply": rpcResp.Result.Reply,
}, nil
}
if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" {
return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil
}
sid := rpcResp.Result.Session.ID
replyText := p.readSSEReply(endpoint, sid, client, timeoutSec)
return map[string]interface{}{
"session_id": sid,
"status": "completed",
"reply": replyText,
}, nil
}
// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本
func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string {
sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Sprintf("(SSE 读取失败: %v)", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bb, _ := io.ReadAll(resp.Body)
return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200))
}
var sb strings.Builder
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for sc.Scan() {
if time.Now().After(deadline) {
break
}
line := strings.TrimRight(sc.Text(), "\r")
if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" {
break
}
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var evt struct {
SessionID string `json:"session_id,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Message *struct {
Text string `json:"text"`
} `json:"message,omitempty"`
}
if json.Unmarshal([]byte(data), &evt) == nil {
text := evt.Text
if evt.Message != nil && evt.Message.Text != "" {
text = evt.Message.Text
}
if text != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(text)
}
}
}
}
if sb.Len() == 0 {
return "(未收到回复)"
}
return sb.String()
}
// ---- Management ----
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
listen, _ := args["listen"].(string)
listen = strings.TrimSpace(listen)
if err := p.sdk.Settings().Set("listen", listen); err != nil {
return fmt.Sprintf("保存配置失败: %v", err), nil
}
if listen == "" || listen == "off" || listen == "disabled" {
p.stopServer()
return "ACP HTTP 服务已禁用", nil
}
if err := p.startServer(listen); err != nil {
return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil
}
return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil
}
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
p.srvMu.Lock()
serverRunning := p.server != nil
listening := p.serverID
p.srvMu.Unlock()
p.mu.RLock()
n := len(p.sessions)
p.mu.RUnlock()
if !serverRunning {
listening = "未运行"
}
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d",
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -1,6 +1,6 @@
# testplugin
# ai_image
testplugin plugin
ai_image plugin
## Build

7
example/ai_image/go.mod Normal file
View File

@ -0,0 +1,7 @@
module ai_image
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/ai_image/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

15
example/ai_image/plg.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "ai_image",
"name_zh": "AI绘图",
"name_en": "AI Image",
"version": "1.0.0",
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["ai", "image", "draw", "generate"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

342
example/ai_image/plugin.go Normal file
View File

@ -0,0 +1,342 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
provider string
model string
size string
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
switch any(def).(type) {
case string:
if sv, ok := v.(string); ok {
return any(sv).(T)
}
case int64:
switch n := v.(type) {
case float64:
return any(int64(n)).(T)
case int64:
return any(n).(T)
case string:
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
return any(i).(T)
}
}
case float64:
switch n := v.(type) {
case float64:
return any(n).(T)
case int64:
return any(float64(n)).(T)
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return any(f).(T)
}
}
}
return def
}
func getArg[T string | int64 | float64](args map[string]interface{}, key string, def T) T {
v, ok := args[key]
if !ok || v == nil {
return def
}
switch any(def).(type) {
case string:
if s, ok := v.(string); ok {
return any(s).(T)
}
case int64:
switch n := v.(type) {
case float64:
return any(int64(n)).(T)
case int64:
return any(n).(T)
case string:
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
return any(i).(T)
}
}
case float64:
switch n := v.(type) {
case float64:
return any(n).(T)
case int64:
return any(float64(n)).(T)
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return any(f).(T)
}
}
}
return def
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 120 * time.Second}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "api_key", Default: "", Type: "string",
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
Category: "ai_image", Secret: true,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "model", Default: "dall-e-3", Type: "string",
DisplayName: "Model", Description: "Model name (dall-e-3, sd-xl, etc.)",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "size", Default: "1024x1024", Type: "string",
DisplayName: "Size", Description: "Default image size (1024x1024, 1024x1792, 1792x1024)",
Category: "ai_image",
})
p.apiKey = getSetting(s.Settings(), "api_key", "")
p.provider = getSetting(s.Settings(), "provider", "openai")
p.model = getSetting(s.Settings(), "model", "dall-e-3")
p.size = getSetting(s.Settings(), "size", "1024x1024")
tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{"type": "string", "description": "Text description of the image to generate"},
"size": map[string]interface{}{"type": "string", "description": "Image size (1024x1024, 1024x1792, 1792x1024), default from config"},
"model": map[string]interface{}{"type": "string", "description": "Model override (dall-e-3, dall-e-2)"},
"n": map[string]interface{}{"type": "integer", "description": "Number of images to generate (1-10), default 1"},
},
"required": []string{"prompt"},
},
}, p.handleGenerate)
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
type openAIReq struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
N int `json:"n"`
Size string `json:"size"`
ResponseFormat string `json:"response_format"`
}
type openAIResp struct {
Created int64 `json:"created"`
Data []struct {
RevisedPrompt string `json:"revised_prompt"`
URL string `json:"url"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error) {
prompt := getArg(args, "prompt", "")
if prompt == "" {
return map[string]interface{}{"isError": true, "content": "prompt is required"}, nil
}
key := getSetting(p.sdk.Settings(), "api_key", p.apiKey)
if key == "" {
return map[string]interface{}{"isError": true, "content": "API key not configured. Set plugin.ai_image.api_key via CLI."}, nil
}
provider := getSetting(p.sdk.Settings(), "provider", p.provider)
model := getArg(args, "model", getSetting(p.sdk.Settings(), "model", p.model))
size := getArg(args, "size", getSetting(p.sdk.Settings(), "size", p.size))
n := getArg(args, "n", int64(1))
if n < 1 {
n = 1
}
if n > 10 {
n = 10
}
switch provider {
case "openai":
return p.generateOpenAI(prompt, model, size, int(n), key)
case "stability":
return p.generateStability(prompt, model, size, int(n), key)
default:
return map[string]interface{}{"isError": true, "content": "Unknown provider: " + provider + ". Supported: openai, stability"}, nil
}
}
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
body := openAIReq{
Model: model,
Prompt: prompt,
N: n,
Size: size,
ResponseFormat: "url",
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := p.client.Do(req)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result openAIResp
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
}
if result.Error != nil {
return map[string]interface{}{"isError": true, "content": "API error: " + result.Error.Message}, nil
}
if len(result.Data) == 0 {
return map[string]interface{}{"isError": true, "content": "No images returned"}, nil
}
urls := make([]string, len(result.Data))
for i, d := range result.Data {
urls[i] = d.URL
}
return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
"images": urls,
"prompt": prompt,
"model": model,
}, nil
}
type stabilityReq struct {
TextPrompts []stabilityPrompt `json:"text_prompts"`
Width int `json:"width"`
Height int `json:"height"`
Samples int `json:"samples"`
}
type stabilityPrompt struct {
Text string `json:"text"`
Weight float64 `json:"weight,omitempty"`
}
type stabilityArtifact struct {
Base64 string `json:"base64"`
Seed int `json:"seed"`
}
type stabilityResp struct {
Artifacts []stabilityArtifact `json:"artifacts"`
Message string `json:"message,omitempty"`
}
func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey string) (interface{}, error) {
width, height := 1024, 1024
if parts := strings.Split(size, "x"); len(parts) == 2 {
if w, err := strconv.Atoi(parts[0]); err == nil {
width = w
}
if h, err := strconv.Atoi(parts[1]); err == nil {
height = h
}
}
body := stabilityReq{
TextPrompts: []stabilityPrompt{{Text: prompt, Weight: 1.0}},
Width: width,
Height: height,
Samples: n,
}
apiURL := "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image"
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}, nil
}
respBody, _ := io.ReadAll(resp.Body)
var result stabilityResp
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
}
if len(result.Artifacts) == 0 {
msg := result.Message
if msg == "" {
msg = "No images returned"
}
return map[string]interface{}{"isError": true, "content": msg}, nil
}
urls := make([]string, len(result.Artifacts))
for i, a := range result.Artifacts {
urls[i] = "data:image/png;base64," + a.Base64
}
return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")),
"images": urls,
"prompt": prompt,
"model": model,
}, nil
}

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/bili/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -7,5 +7,9 @@
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["bili", "video", "download"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -8,13 +8,15 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
name string
sdk *sdk.PluginSDK
proxy string
}
func (p *Plugin) Name() string { return p.name }
@ -25,11 +27,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
tp := p.name + "_"
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin." + p.name + ".output_dir", Default: "/tmp/bili_videos",
Key: "output_dir", Default: "/tmp/bili_videos",
Type: "string", DisplayName: "下载目录",
Description: "B站视频下载后的保存目录",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "proxy", Default: "",
Type: "string", DisplayName: "HTTP 代理",
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890留空则不设置",
Category: p.name,
})
if v, _ := s.Settings().Get("proxy"); v != nil {
if str, ok := v.(string); ok {
p.proxy = str
}
}
s.RegisterTool(tp+"video", sdk.ToolDef{
Name: tp + "video",
@ -43,6 +56,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
"required": []string{"url"},
},
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleBiliVideo)
return nil
}
@ -81,7 +101,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
outputDir := "/tmp/bili_videos"
if p.sdk != nil {
if v, _ := p.sdk.Settings().Get("plugin." + p.name + ".output_dir"); v != nil {
if v, _ := p.sdk.Settings().Get("output_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
outputDir = s
}
@ -94,7 +114,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
cmd := exec.Command("yt-dlp", ytdlpArgs...)
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
cmd.Env = proxyEnv(p.proxy)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
}
@ -164,12 +184,17 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
if err := os.MkdirAll(taskDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir task dir: %w", err)
}
dlArgs := []string{
"--no-warnings",
"--socket-timeout", "30",
"--retries", "3",
"--fragment-retries", "3",
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
"--no-overwrites",
}
if format != "" {
@ -177,7 +202,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
}
dlArgs = append(dlArgs, url)
cmd2 := exec.Command("yt-dlp", dlArgs...)
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer
cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut
@ -185,9 +210,18 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
}
entries, _ := os.ReadDir(outputDir)
var newest string
var newestTime int64
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
for _, f := range parts {
os.Remove(f)
}
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
for _, f := range residuals {
os.Remove(f)
}
entries, _ := os.ReadDir(taskDir)
var mainFile string
var mainSize int64
for _, e := range entries {
if e.IsDir() {
continue
@ -196,30 +230,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
if fi == nil {
continue
}
t := fi.ModTime().Unix()
if t > newestTime {
newestTime = t
newest = e.Name()
if fi.Size() > mainSize {
mainSize = fi.Size()
mainFile = e.Name()
}
}
if newest == "" {
if mainFile == "" {
return map[string]interface{}{
"content": "下载完成,但未找到视频文件",
}, nil
}
dlPath := filepath.Join(outputDir, newest)
fi, _ := os.Stat(dlPath)
var fileSize int64
if fi != nil {
fileSize = fi.Size()
}
dlPath := filepath.Join(taskDir, mainFile)
return map[string]interface{}{
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
"file": dlPath,
"filename": newest,
"filename": mainFile,
}, nil
}
func proxyEnv(proxy string) []string {
env := os.Environ()
if proxy != "" {
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
}
return env
}
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
@ -229,6 +265,6 @@ func contains(slice []string, s string) bool {
return false
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -1,101 +0,0 @@
/* Code generated by cmd/cgo; DO NOT EDIT. */
/* package bili */
#line 1 "cgo-builtin-export-prolog"
#include <stddef.h>
#ifndef GO_CGO_EXPORT_PROLOGUE_H
#define GO_CGO_EXPORT_PROLOGUE_H
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
extern size_t _GoStringLen(_GoString_ s);
extern const char *_GoStringPtr(_GoString_ s);
#endif
#endif
/* Start of preamble from import "C" comments. */
#line 3 "z_bridge_gen.go"
#include <stdlib.h>
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
#line 1 "cgo-generated-wrapper"
/* End of preamble from import "C" comments. */
/* Start of boilerplate cgo prologue. */
#line 1 "cgo-gcc-export-header-prolog"
#ifndef GO_CGO_PROLOGUE_H
#define GO_CGO_PROLOGUE_H
typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef size_t GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
#ifdef _MSC_VER
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
#include <complex.h>
typedef _Fcomplex GoComplex64;
typedef _Dcomplex GoComplex128;
#else
#include <complex>
typedef std::complex<float> GoComplex64;
typedef std::complex<double> GoComplex128;
#endif
#else
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
#endif
/*
static assertion to make sure the file is being used on architecture
at least with matching size of GoInt.
*/
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef _GoString_ GoString;
#endif
typedef void *GoMap;
typedef void *GoChan;
typedef struct { void *t; void *v; } GoInterface;
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
#endif
/* End of boilerplate cgo prologue. */
#ifdef __cplusplus
extern "C" {
#endif
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
extern int go_stop_plugin(char** errorOut);
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
extern void go_free_string(char* ptr);
#ifdef __cplusplus
}
#endif

View File

@ -2,6 +2,25 @@ module browser
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
require (
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
github.com/chromedp/chromedp v0.9.5
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
github.com/chromedp/sysutil v1.0.0
github.com/gobwas/httphead v0.1.0
github.com/gobwas/pool v0.2.1
github.com/gobwas/ws v1.3.2
github.com/josharian/intern v1.0.0
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80
github.com/mailru/easyjson v0.7.7
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde
golang.org/x/sys v0.16.0
)
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

23
example/browser/go.sum Normal file
View File

@ -0,0 +1,23 @@
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

11
example/browser/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -1,11 +1,15 @@
{
"name": "browser",
"name_zh": "浏览器",
"name_en": "browser",
"version": "1.0.0",
"description": "网络资源搜索与获取搜索引擎查询browser_search、网页抓取browser_fetchSSRF防护、无头浏览器渲染browser_render",
"name_en": "Browser",
"version": "2.0.0",
"description": "统一浏览器插件搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["web", "search", "fetch", "browser"],
"targets": "linux/amd64"
"tags": ["web", "search", "fetch", "browser", "cdp"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
# calendar
calendar plugin
## Build
```bash
plugindev build
```
## Install
Upload the .hmap file through the Plugin Manager API.

7
example/calendar/go.mod Normal file
View File

@ -0,0 +1,7 @@
module calendar
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/calendar/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

15
example/calendar/plg.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "calendar",
"name_zh": "日历",
"name_en": "Calendar",
"version": "1.0.0",
"description": "日历事件管理,支持提醒和重复事件",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["calendar", "event", "reminder", "schedule"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

1179
example/calendar/plugin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/editdoc/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -7,5 +7,9 @@
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["editdoc", "office", "document"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -4,15 +4,19 @@ import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
name string
sdk *sdk.PluginSDK
scriptPath string
venvPython string
}
func (p *Plugin) Name() string { return p.name }
@ -20,9 +24,34 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "script_path", Default: "", Type: "string",
DisplayName: "编辑脚本路径",
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "venv_python", Default: "", Type: "string",
DisplayName: "venv Python 解释器",
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python必须配置留空将报错",
Category: p.name,
})
if v, err := s.Settings().Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = str
}
}
if v, err := s.Settings().Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = str
}
}
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -79,19 +108,24 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
}
pyArgsJSON, _ := json.Marshal(pyArgs)
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py"
scriptPath := p.scriptPath
if scriptPath == "" {
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
}
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath)
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
}
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3"
pythonBin := "python3"
if _, err := os.Stat(venvPython); err == nil {
pythonBin = venvPython
if p.venvPython == "" {
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_pythonvenv 内 python 的绝对路径)")
}
if _, err := os.Stat(p.venvPython); err != nil {
return nil, fmt.Errorf("venv python 不存在: %s请检查 venv_python 配置)", p.venvPython)
}
var out bytes.Buffer
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON))
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("edit document: %w", err)
@ -124,6 +158,6 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

7
example/files/go.mod Normal file
View File

@ -0,0 +1,7 @@
module files
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/files/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -7,5 +7,9 @@
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["files", "filesystem"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
@ -25,25 +26,40 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.files.dir",
Default: "/",
Key: "dir",
Default: "",
Type: "string",
DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox不建议设为 /",
Category: "files",
})
dir := getSetting[string](s.Settings(), "dir", "/")
dir := getSetting[string](s.Settings(), "dir", "")
if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:])
}
if dir == "" {
dataDir, err := s.Settings().GetCore("core.daemon.data_dir")
base := "."
if err == nil {
if ds, ok := dataDir.(string); ok && ds != "" {
base = ds
}
}
dir = filepath.Join(base, "files_sandbox")
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve files.dir: %w", err)
}
if err := os.MkdirAll(abs, 0755); err != nil {
return fmt.Errorf("mkdir files.dir: %w", err)
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
p.filesDir = abs
os.MkdirAll(p.filesDir, 0755)
tp := p.name + "_"
@ -59,6 +75,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
"required": []string{"path"},
},
NoMemory: false,
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
@ -134,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) {
return "", fmt.Errorf("resolve path: %w", err)
}
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
if !withinSandbox(base, abs) {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
return abs, nil
real, err := evalReal(base, abs)
if err != nil {
return "", err
}
if !withinSandbox(base, real) {
return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath)
}
return real, nil
}
func withinSandbox(base, abs string) bool {
if base == "/" {
return true
}
return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator))
}
func evalReal(base, abs string) (string, error) {
existing := abs
var tail []string
for {
real, err := filepath.EvalSymlinks(existing)
if err == nil {
full := real
for i := len(tail) - 1; i >= 0; i-- {
full = filepath.Join(full, tail[i])
}
return full, nil
}
if !os.IsNotExist(err) {
return "", fmt.Errorf("resolve path: %w", err)
}
if link, lerr := os.Readlink(existing); lerr == nil {
target := link
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(existing), target)
}
if t, aerr := filepath.Abs(target); aerr == nil {
target = filepath.Clean(t)
}
if !withinSandbox(base, target) {
return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs)
}
}
parent := filepath.Dir(existing)
if parent == existing {
return "", fmt.Errorf("resolve path: %w", err)
}
tail = append(tail, filepath.Base(existing))
existing = parent
}
}
// handleRead implements the read tool.
@ -478,6 +552,6 @@ func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
return val
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

25
example/luademo/README.md Normal file
View File

@ -0,0 +1,25 @@
# luademo
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
- **工具注册**`no_memory` + `cleaner`(记忆计算层过滤)
- **阶段钩子**`register_stage(stage, handler, scope)``own_tools` 与全局作用域
- **通道**`register_output_channel` / `register_input_channel`def 支持 no_memory/cleaner
- **数据类 API**`sdk.memory.*``sdk.doc.*``sdk.knowledge.*``sdk.text_memory.*``sdk.llm.*``sdk.settings.*``sdk.social.*`
- **其他**`register_api``set_auto_restart`
## 本地独立测试
```bash
lua main.lua # 使用 sdk.lua mock不依赖内核
```
## 构建
```bash
plugindev build
```
## 安装
通过插件管理 HTTP API 上传 `.hmap` 包,或解压到 `<data>/plugins/luademo/` 后重启内核。

105
example/luademo/main.lua Normal file
View File

@ -0,0 +1,105 @@
-- luademo plugin — 展示 v0.8.0 Lua SDK 全部能力
-- 运行环境内核注入真实实现lua main.lua 可用 sdk.lua mock 独立测试
local plugin = { name = "luademo" }
function plugin.start(sdk)
sdk.log("info", "luademo starting...")
-- 注册配置项WebUI 可展示)
sdk.settings.register_def({
key = "plugin.luademo.greeting",
default = "Hello",
type = "string",
display_name = "Greeting",
description = "Greeting prefix for the hello tool",
category = "luademo",
})
-- 注册工具no_memory输出跳过记忆计算+ cleaner计算层过滤函数
sdk.register_tool("luademo_hello", {
description = "A hello world tool with no_memory and cleaner",
parameters = { type = "object", properties = {} },
no_memory = true,
cleaner = function(text) return "CLEANED:" .. text end,
}, function(args)
local prefix, err = sdk.settings.get_core("plugin.luademo.greeting")
if err ~= nil then prefix = "Hello" end
return { content = (prefix or "Hello") .. " from luademo plugin!" }
end)
-- 注册工具:数据类 API 巡检memory/doc/knowledge/text_memory/llm/settings/social
sdk.register_tool("luademo_probe", {
description = "Exercise every aligned data API and return combined results",
parameters = { type = "object", properties = {} },
no_memory = true,
}, function(args)
local res = {}
local ok, err = sdk.memory.commit({ { subject = "demo", relation = "uses", object = "lua" } })
res.memory_commit = { ok = ok, err = err }
local recalled, rerr = sdk.memory.recall("demo", 1)
res.memory_recall = { result = recalled, err = rerr }
ok, err = sdk.doc.insert({ id = "demo-1", title = "lua demo doc", content = "hello lua world" })
res.doc_insert = { ok = ok, err = err }
local docs, derr = sdk.doc.query("lua", 2)
res.doc_query = { result = docs, err = derr }
ok, err = sdk.knowledge.add("luademo", "lua knowledge entry")
res.knowledge_add = { ok = ok, err = err }
local entries, kerr = sdk.knowledge.search("luademo", 2)
res.knowledge_search = { result = entries, err = kerr }
ok, err = sdk.text_memory.append({ role = "tool", content = "luademo probe ran", channel = "luademo" })
res.text_memory = { ok = ok, err = err }
local sources, serr = sdk.llm.list_sources()
res.llm_sources = { result = sources, err = serr }
local v, verr = sdk.settings.get_core("agent.name")
res.settings_get_core = { result = v, err = verr }
local defs, defserr = sdk.settings.defs("plugin.luademo")
res.settings_defs = { result = defs, err = defserr }
local persons, perr = sdk.social.list_persons()
res.social_persons = { result = persons, err = perr }
return { content = res }
end)
-- 阶段钩子own_tools 作用域(仅本插件工具被调用时触发)
sdk.register_stage("before_toolcall", function(ctx)
local calls = ctx.tool_calls or {}
if calls[1] then
sdk.log("info", "luademo stage before_toolcall: tool=" .. tostring(calls[1].name))
end
return nil
end, "own_tools")
-- 阶段钩子:全局作用域(修改 ctx 字段会写回内核,见 applyLuaStageResult
sdk.register_stage("pre_action", function(ctx)
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
-- 演示 stage 写回:给 llm_text 追加标记(内核会同步回 StageContext
if ctx.llm_text then
ctx.llm_text = ctx.llm_text .. "[luademo]"
end
return nil
end)
-- 输出通道路由输出到外部渠道def 支持 no_memory/cleaner
sdk.register_output_channel("luademo_out", 0, "luademo push channel",
{ no_memory = true, cleaner = function(t) return "OCLEANED:" .. t end },
function(args) return { content = "out-channel ack" } end)
-- 输入通道
sdk.register_input_channel("luademo_in", { no_memory = true })
-- 其他 API
sdk.register_api("luademo.ping")
sdk.set_auto_restart(true)
sdk.log("info", "luademo started")
end
function plugin.stop() sdk.log("info", "luademo stopped") end
return plugin

11
example/luademo/plg.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "luademo",
"name_zh": "Lua 全功能示例",
"name_en": "Lua Demo",
"version": "0.1.0",
"description": "Lua 插件全功能示例:工具(no_memory/cleaner) + 阶段钩子 + 通道 + 数据类 API",
"author": "HomeAgent",
"entry": "main.lua",
"tags": ["luademo"],
"targets": "lua"
}

67
example/luademo/sdk.lua Normal file
View File

@ -0,0 +1,67 @@
-- HomeAgent Lua Plugin SDK (standalone mock)
sdk = {}
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
sdk.memory = {}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
function sdk.memory.commit(triples) return nil end
function sdk.memory.introspect() return {} end
function sdk.memory.merge(source, target) return 0 end
function sdk.memory.purge(criteria, hard) return 0 end
sdk.doc = {}
function sdk.doc.query(text, top_k) return {} end
function sdk.doc.insert(doc) return nil end
function sdk.doc.remove(id) return nil end
function sdk.doc.stats() return {} end
sdk.knowledge = {}
function sdk.knowledge.search(query, limit) return {} end
function sdk.knowledge.add(tag, content) return nil end
function sdk.knowledge.list() return {} end
sdk.text_memory = {}
function sdk.text_memory.append(evt) return nil end
sdk.llm = {}
function sdk.llm.list_sources() return {} end
function sdk.llm.set_source(name) return nil end
function sdk.llm.current_source() return nil end
sdk.social = {}
function sdk.social.get_person(name) return {} end
function sdk.social.get_network(name, depth) return {} end
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
function sdk.social.get_relations(name) return {} end
function sdk.social.list_persons() return {} end
sdk.settings = {}
function sdk.settings.get_core(key) return nil end
function sdk.settings.set_core(key, value) return nil end
function sdk.settings.list_core(prefix) return {} end
function sdk.settings.get_plugin(plugin, key) return nil end
function sdk.settings.set_plugin(plugin, key, value) return nil end
function sdk.settings.list_plugin(plugin, prefix) return {} end
function sdk.settings.list(prefix) return {} end
function sdk.settings.register_def(def) return nil end
function sdk.settings.defs(prefix) return {} end
function sdk.settings.dump() return {} end
function sdk.settings.plugins() return {} end
sdk.json = {}
function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
elseif type(val) == "table" then local parts, i = {}, 1
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
return "{" .. table.concat(parts, ",") .. "}" end
return "null"
end
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
sdk.http = {}
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
return sdk

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/memo/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -1,11 +1,15 @@
{
"name": "memo",
"name_zh": "备忘录",
"name_en": "Memo/Notes",
"name_en": "Memo",
"version": "1.0.0",
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
"description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["memo", "todo", "notes"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -13,20 +13,31 @@ import (
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Memo struct {
// Todo 待办条目:会被主动提醒
type Todo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
Done bool `json:"done"`
}
// Memo 备忘录条目:纯记事,不主动提醒
type Memo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
todos []Todo
nextTID int64
memos []Memo
nextID int64
filePath string
nextMID int64
todoPath string
memoPath string
stopCh chan struct{}
tp string
}
@ -37,70 +48,154 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.tp = p.name + "_"
p.stopCh = make(chan struct{})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
p.load()
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
}
p.todoPath = filepath.Join(dir, "todos.json")
p.memoPath = filepath.Join(dir, "memos.json")
p.loadTodos()
p.loadMemos()
s.RegisterTool(p.tp+"create", sdk.ToolDef{
Name: p.tp + "create",
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
// 卸载(删除)时清理数据文件;重载不触发
s.RegisterOnRemoveHandler(p.cleanupData)
// ── 待办(会被主动提醒)──
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
Name: p.tp + "todo_add",
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
},
"required": []string{"content"},
},
}, p.handleCreate)
}, p.handleTodoAdd)
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
Name: p.tp + "complete",
Description: "将指定ID的备忘标记为已完成。",
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
Name: p.tp + "todo_complete",
Description: "将指定ID的待办标记为已完成(不再提醒)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
},
"required": []string{"id"},
},
}, p.handleComplete)
}, p.handleTodoComplete)
s.RegisterTool(p.tp+"list", sdk.ToolDef{
Name: p.tp + "list",
Description: "列出所有未完成的备忘条目包含ID、内容和创建时间。",
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
Name: p.tp + "todo_list",
Description: "列出所有未完成的待办事项包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleList)
}, p.handleTodoList)
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
Name: p.tp + "todo_delete",
Description: "删除指定ID的待办事项包括已完成的。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
},
"required": []string{"id"},
},
}, p.handleTodoDelete)
// ── 备忘(纯记事,不提醒)──
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
Name: p.tp + "memo_create",
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
},
"required": []string{"content"},
},
}, p.handleMemoCreate)
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
Name: p.tp + "memo_list",
Description: "列出所有备忘录包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleMemoList)
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
Name: p.tp + "memo_delete",
Description: "删除指定ID的备忘录。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
},
"required": []string{"id"},
},
}, p.handleMemoDelete)
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
go p.periodicCheck()
log.Printf("[%s] started, path=%s", p.name, p.filePath)
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
return nil
}
func (p *Plugin) Stop() error {
close(p.stopCh)
p.save()
p.saveTodos()
p.saveMemos()
log.Printf("[%s] stopped", p.name)
return nil
}
func (p *Plugin) load() {
func (p *Plugin) loadTodos() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.filePath)
data, err := os.ReadFile(p.todoPath)
if err != nil {
p.memos = nil
p.nextID = 1
p.todos = []Todo{}
p.nextTID = 1
return
}
var store struct {
Todos []Todo `json:"todos"`
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.todos = []Todo{}
p.nextTID = 1
return
}
p.todos = store.Todos
p.nextTID = store.NextID
if p.todos == nil {
p.todos = []Todo{}
}
if p.nextTID < 1 {
p.nextTID = 1
}
}
func (p *Plugin) loadMemos() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.memoPath)
if err != nil {
p.memos = []Memo{}
p.nextMID = 1
return
}
var store struct {
@ -108,66 +203,82 @@ func (p *Plugin) load() {
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.memos = nil
p.nextID = 1
p.memos = []Memo{}
p.nextMID = 1
return
}
p.memos = store.Memos
p.nextID = store.NextID
p.nextMID = store.NextID
if p.memos == nil {
p.memos = []Memo{}
}
if p.nextID < 1 {
p.nextID = 1
if p.nextMID < 1 {
p.nextMID = 1
}
}
func (p *Plugin) save() {
func (p *Plugin) saveTodos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos,
"next_id": p.nextID,
"todos": p.todos,
"next_id": p.nextTID,
}, "", " ")
os.WriteFile(p.filePath, data, 0644)
p.mu.RUnlock()
os.WriteFile(p.todoPath, data, 0644)
}
func (p *Plugin) pendingCount() int {
func (p *Plugin) saveMemos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos,
"next_id": p.nextMID,
}, "", " ")
p.mu.RUnlock()
os.WriteFile(p.memoPath, data, 0644)
}
// ── 待办:未完成计数与提醒 ──
func (p *Plugin) pendingTodoCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
n := 0
for _, m := range p.memos {
if !m.Done {
for _, t := range p.todos {
if !t.Done {
n++
}
}
return n
}
func (p *Plugin) pendingMemos() []Memo {
func (p *Plugin) pendingTodos() []Todo {
p.mu.RLock()
defer p.mu.RUnlock()
var out []Memo
for _, m := range p.memos {
if !m.Done {
out = append(out, m)
var out []Todo
for _, t := range p.todos {
if !t.Done {
out = append(out, t)
}
}
return out
}
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
n := p.pendingCount()
n := p.pendingTodoCount()
if n == 0 {
return nil
}
ctx.Lock()
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system",
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
})
ctx.Unlock()
return nil
}
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
func (p *Plugin) periodicCheck() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
@ -176,19 +287,124 @@ func (p *Plugin) periodicCheck() {
case <-p.stopCh:
return
case <-ticker.C:
n := p.pendingCount()
n := p.pendingTodoCount()
if n == 0 {
continue
}
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
}
}
}
}
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
// ── 待办工具 ──
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return errorResult("content is required"), nil
}
p.mu.Lock()
todo := Todo{
ID: p.nextTID,
Content: content,
CreatedAt: time.Now().Unix(),
Done: false,
}
p.nextTID++
p.todos = append(p.todos, todo)
p.mu.Unlock()
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
"id": todo.ID,
}, nil
}
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.todos {
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
p.todos[i].Done = true
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
}
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
}, nil
}
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
todos := p.pendingTodos()
if len(todos) == 0 {
return map[string]interface{}{
"content": "暂无未完成的待办",
}, nil
}
var sb strings.Builder
for i, t := range todos {
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
}
return map[string]interface{}{
"content": sb.String(),
"count": len(todos),
}, nil
}
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.todos {
if p.todos[i].ID == int64(id) {
p.todos = append(p.todos[:i], p.todos[i+1:]...)
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
}
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
}, nil
}
// ── 备忘工具 ──
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return errorResult("content is required"), nil
@ -196,23 +412,22 @@ func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error)
p.mu.Lock()
memo := Memo{
ID: p.nextID,
ID: p.nextMID,
Content: content,
CreatedAt: time.Now().Unix(),
Done: false,
}
p.nextID++
p.nextMID++
p.memos = append(p.memos, memo)
p.mu.Unlock()
p.save()
p.saveMemos()
return map[string]interface{}{
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
"id": memo.ID,
}, nil
}
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
@ -221,8 +436,8 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
p.mu.Lock()
found := false
for i := range p.memos {
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
p.memos[i].Done = true
if p.memos[i].ID == int64(id) {
p.memos = append(p.memos[:i], p.memos[i+1:]...)
found = true
break
}
@ -230,30 +445,33 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
return errorResult(fmt.Sprintf("未找到备忘 ID: %d", int64(id))), nil
}
p.save()
p.saveMemos()
return map[string]interface{}{
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
"content": fmt.Sprintf("备忘 %d 已删除", int64(id)),
}, nil
}
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
memos := p.pendingMemos()
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
p.mu.RLock()
memos := append([]Memo{}, p.memos...)
p.mu.RUnlock()
if len(memos) == 0 {
return map[string]interface{}{
"content": "暂无未完成的备忘",
"content": "暂无备忘",
}, nil
}
var sb strings.Builder
for i, m := range memos {
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
}
return map[string]interface{}{
@ -269,6 +487,16 @@ func errorResult(msg string) map[string]interface{} {
}
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
}
// cleanupData 卸载时清理数据文件(待办 + 备忘)
func (p *Plugin) cleanupData() {
if p.todoPath != "" {
os.Remove(p.todoPath)
}
if p.memoPath != "" {
os.Remove(p.memoPath)
}
}

7
example/music/go.mod Normal file
View File

@ -0,0 +1,7 @@
module music
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/music/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

15
example/music/plg.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "music",
"name_zh": "音乐搜索",
"name_en": "Music Search",
"version": "0.1.0",
"description": "音乐搜索插件,支持搜索歌曲和查看歌词(基于网易云音乐)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["music", "song", "lyrics", "网易云"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

327
example/music/plugin.go Normal file
View File

@ -0,0 +1,327 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
cli *http.Client
}
type searchResp struct {
Result *struct {
Songs []songItem `json:"songs"`
SongCount int `json:"songCount"`
} `json:"result"`
Code int `json:"code"`
}
type songItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Artists []artist `json:"artists"`
Album albumInfo `json:"album"`
Duration int `json:"duration"`
Mvid int `json:"mvid"`
Fee int `json:"fee"`
}
type artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type albumInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type lyricResp struct {
Lrc *lyricData `json:"lrc"`
TLrc *lyricData `json:"tlyric"`
Code int `json:"code"`
}
type lyricData struct {
Lyric string `json:"lyric"`
}
func NewPluginFactory(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 {
s.SetAutoRestart(true)
p.sdk = s
p.cli = &http.Client{Timeout: 15 * time.Second}
s.RegisterTool(p.name+"_search", sdk.ToolDef{
Name: p.name + "_search",
Description: "搜索歌曲,通过关键词查找音乐,返回歌曲列表",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"keyword": map[string]interface{}{
"type": "string",
"description": "搜索关键词,如歌曲名、歌手名",
},
"limit": map[string]interface{}{
"type": "integer",
"description": "返回结果数量1-50默认10",
},
},
"required": []string{"keyword"},
},
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleSearch)
s.RegisterTool(p.name+"_lyrics", sdk.ToolDef{
Name: p.name + "_lyrics",
Description: "获取歌曲歌词通过歌曲ID查看歌词内容",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"song_id": map[string]interface{}{
"type": "integer",
"description": "歌曲ID从搜索结果的 id 字段获取)",
},
},
"required": []string{"song_id"},
},
}, p.handleLyrics)
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) neRequest(path string, params map[string]string) ([]byte, error) {
base := "https://music.163.com/api" + path
reqURL := base + "?" + urlValues(params).Encode()
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Referer", "https://music.163.com/")
resp, err := p.cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func urlValues(m map[string]string) url.Values {
v := url.Values{}
for k, val := range m {
v.Set(k, val)
}
return v
}
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
keyword, _ := args["keyword"].(string)
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return map[string]interface{}{
"content": "请输入搜索关键词",
"isError": true,
}, nil
}
limit := 10
if v, ok := args["limit"].(float64); ok {
limit = int(v)
if limit < 1 {
limit = 1
}
if limit > 50 {
limit = 50
}
}
body, err := p.neRequest("/search/get", map[string]string{
"s": keyword,
"type": "1",
"limit": fmt.Sprint(limit),
})
if err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("搜索失败:%v", err),
"isError": true,
}, nil
}
var resp searchResp
if err := json.Unmarshal(body, &resp); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("解析响应失败:%v", err),
"isError": true,
}, nil
}
if resp.Code != 200 || resp.Result == nil {
return map[string]interface{}{
"content": fmt.Sprintf("搜索失败,响应码:%d", resp.Code),
"isError": true,
}, nil
}
songs := resp.Result.Songs
if len(songs) == 0 {
return map[string]interface{}{
"content": fmt.Sprintf("未找到与「%s」相关的歌曲", keyword),
}, nil
}
var lines []string
lines = append(lines, fmt.Sprintf("找到 %d 首与「%s」相关的歌曲\n", resp.Result.SongCount, keyword))
for i, s := range songs {
var artists []string
for _, a := range s.Artists {
artists = append(artists, a.Name)
}
dur := time.Duration(s.Duration) * time.Millisecond
minutes := int(dur.Minutes())
seconds := int(dur.Seconds()) % 60
lines = append(lines, fmt.Sprintf("%d. %s - %s [%02d:%02d] (ID: %d)",
i+1, s.Name, strings.Join(artists, "/"), minutes, seconds, s.ID))
}
type songResult struct {
ID int64 `json:"id"`
Name string `json:"name"`
Artists []string `json:"artists"`
Album string `json:"album"`
Duration int `json:"duration"`
}
var results []songResult
for _, s := range songs {
var artists []string
for _, a := range s.Artists {
artists = append(artists, a.Name)
}
results = append(results, songResult{
ID: s.ID,
Name: s.Name,
Artists: artists,
Album: s.Album.Name,
Duration: s.Duration,
})
}
return map[string]interface{}{
"content": strings.Join(lines, "\n"),
"songs": results,
"total": resp.Result.SongCount,
}, nil
}
func (p *Plugin) handleLyrics(args map[string]interface{}) (interface{}, error) {
songID, ok := args["song_id"].(float64)
if !ok {
return map[string]interface{}{
"content": "请提供有效的歌曲ID",
"isError": true,
}, nil
}
id := int64(songID)
body, err := p.neRequest("/song/lyric", map[string]string{
"id": fmt.Sprint(id),
"lv": "-1",
"kv": "-1",
"tv": "-1",
})
if err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("获取歌词失败:%v", err),
"isError": true,
}, nil
}
var resp lyricResp
if err := json.Unmarshal(body, &resp); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("解析歌词失败:%v", err),
"isError": true,
}, nil
}
if resp.Code != 200 {
return map[string]interface{}{
"content": fmt.Sprintf("获取歌词失败,响应码:%d", resp.Code),
"isError": true,
}, nil
}
lyric := ""
if resp.Lrc != nil {
lyric = resp.Lrc.Lyric
}
if lyric == "" {
return map[string]interface{}{
"content": fmt.Sprintf("歌曲 %d 暂无歌词", id),
}, nil
}
// Clean up lyrics metadata lines and limit length
lyric = cleanLyrics(lyric)
if len(lyric) > 3000 {
lyric = lyric[:3000] + "\n...(歌词过长已截断)"
}
tLyric := ""
if resp.TLrc != nil && resp.TLrc.Lyric != "" {
tLyric = cleanLyrics(resp.TLrc.Lyric)
if len(tLyric) > 1000 {
tLyric = tLyric[:1000] + "\n...(翻译过长已截断)"
}
}
result := fmt.Sprintf("歌词:\n%s", lyric)
if tLyric != "" {
result += fmt.Sprintf("\n翻译\n%s", tLyric)
}
return map[string]interface{}{
"content": result,
"lyric": lyric,
"tlyric": tLyric,
}, nil
}
func cleanLyrics(l string) string {
lines := strings.Split(l, "\n")
var cleaned []string
for _, line := range lines {
// Skip metadata lines like [ti:...], [ar:...], [al:...], [by:...], [offset:...]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
cleaned = append(cleaned, line)
}
return strings.Join(cleaned, "\n")
}

View File

@ -2,6 +2,6 @@ module ocr
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/ocr/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -7,5 +7,9 @@
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["ocr", "image", "text"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -2,6 +2,7 @@ package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
@ -38,6 +39,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"language": map[string]interface{}{"type": "string", "description": "识别语言,默认 chi_sim+eng中文简体+英文),可选 chi_sim / eng / chi_sim+eng"},
},
},
Cleaner: func(output string) string {
var r struct{ Text string }
if json.Unmarshal([]byte(output), &r) == nil && r.Text != "" {
return r.Text
}
var r2 struct{ Content string }
if json.Unmarshal([]byte(output), &r2) == nil && r2.Content != "" {
return r2.Content
}
return output
},
}, p.handleOcrImage)
log.Printf("[%s] plugin started", p.name)
@ -128,7 +140,6 @@ func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error
}, nil
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
}

View File

@ -2,6 +2,6 @@ module qq
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/qq/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -7,5 +7,9 @@
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["qq", "messaging"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": false,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -1,101 +0,0 @@
/* Code generated by cmd/cgo; DO NOT EDIT. */
/* package qq */
#line 1 "cgo-builtin-export-prolog"
#include <stddef.h>
#ifndef GO_CGO_EXPORT_PROLOGUE_H
#define GO_CGO_EXPORT_PROLOGUE_H
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
extern size_t _GoStringLen(_GoString_ s);
extern const char *_GoStringPtr(_GoString_ s);
#endif
#endif
/* Start of preamble from import "C" comments. */
#line 3 "z_bridge_gen.go"
#include <stdlib.h>
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
#line 1 "cgo-generated-wrapper"
/* End of preamble from import "C" comments. */
/* Start of boilerplate cgo prologue. */
#line 1 "cgo-gcc-export-header-prolog"
#ifndef GO_CGO_PROLOGUE_H
#define GO_CGO_PROLOGUE_H
typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef size_t GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
#ifdef _MSC_VER
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
#include <complex.h>
typedef _Fcomplex GoComplex64;
typedef _Dcomplex GoComplex128;
#else
#include <complex>
typedef std::complex<float> GoComplex64;
typedef std::complex<double> GoComplex128;
#endif
#else
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
#endif
/*
static assertion to make sure the file is being used on architecture
at least with matching size of GoInt.
*/
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef _GoString_ GoString;
#endif
typedef void *GoMap;
typedef void *GoChan;
typedef struct { void *t; void *v; } GoInterface;
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
#endif
/* End of boilerplate cgo prologue. */
#ifdef __cplusplus
extern "C" {
#endif
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
extern int go_stop_plugin(char** errorOut);
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
extern void go_free_string(char* ptr);
#ifdef __cplusplus
}
#endif

13
example/rss/README.md Normal file
View File

@ -0,0 +1,13 @@
# rss
rss plugin
## Build
```bash
plugindev build
```
## Install
Upload the .hmap file through the Plugin Manager API.

17
example/rss/go.mod Normal file
View File

@ -0,0 +1,17 @@
module rss
go 1.25.0
require (
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
github.com/mmcdole/gofeed v1.4.0
github.com/mmcdole/goxpp/v2 v2.0.0
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
)
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

8
example/rss/go.sum Normal file
View File

@ -0,0 +1,8 @@
github.com/mmcdole/gofeed v1.4.0 h1:+efDmI/yJXJgTfa8we5zg9GAKsU+2d7tnpt9QZwvjLQ=
github.com/mmcdole/gofeed v1.4.0/go.mod h1:ngV5MTB7UJko6fH3/fG5AkB/ABUGK1ZTePF9iRhzu/c=
github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ=
github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=

11
example/rss/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

15
example/rss/plg.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "rss",
"name_zh": "RSS订阅",
"name_en": "RSS",
"version": "1.0.0",
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["rss", "feed", "subscription", "monitor"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

488
example/rss/plugin.go Normal file
View File

@ -0,0 +1,488 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/mmcdole/gofeed"
)
const injectDedupWindow = 5 * time.Minute
type FeedSub struct {
URL string `json:"url"`
Title string `json:"title"`
AddedAt string `json:"added_at"`
Interval int `json:"interval"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
fp *gofeed.Parser
dataDir string
mu sync.RWMutex
feeds []FeedSub
seenGUIDs map[string]bool
injected map[string]time.Time
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
pollTicker *time.Ticker
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return fallback
}
switch any(fallback).(type) {
case string:
if sv, ok := v.(string); ok {
return any(sv).(T)
}
case int64:
switch val := v.(type) {
case float64:
return any(int64(val)).(T)
case string:
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
return any(n).(T)
}
}
case float64:
switch val := v.(type) {
case float64:
return any(val).(T)
case string:
if n, err := strconv.ParseFloat(val, 64); err == nil {
return any(n).(T)
}
}
}
return fallback
}
func readArg(args map[string]interface{}, key string) string {
if v, ok := args[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func readArgInt(args map[string]interface{}, key string, fallback int) int {
if v, ok := args[key]; ok && v != nil {
switch n := v.(type) {
case float64:
return int(n)
case int64:
return int(n)
}
}
return fallback
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 30 * time.Second}
p.fp = gofeed.NewParser()
p.stopCh = make(chan struct{})
p.seenGUIDs = make(map[string]bool)
p.injected = make(map[string]time.Time)
p.feeds = []FeedSub{}
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
}
p.loadData()
// 卸载(删除)时清理订阅数据目录;重载不触发
s.RegisterOnRemoveHandler(p.cleanupData)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "poll_interval", Default: "30", Type: "string",
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
Category: "rss",
})
tp := p.name + "_"
s.RegisterTool(tp+"subscribe", sdk.ToolDef{
Name: tp + "subscribe", Description: "Subscribe to an RSS/Atom feed URL",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "Feed URL"},
"interval": map[string]interface{}{"type": "integer", "description": "Poll interval in minutes (default: 30, minimum: 5)"},
},
"required": []string{"url"},
},
}, p.handleSubscribe)
s.RegisterTool(tp+"unsubscribe", sdk.ToolDef{
Name: tp + "unsubscribe", Description: "Unsubscribe from a feed",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "Feed URL to unsubscribe"},
},
"required": []string{"url"},
},
}, p.handleUnsubscribe)
s.RegisterTool(tp+"list", sdk.ToolDef{
Name: tp + "list", Description: "List all subscribed feeds",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleList)
s.RegisterTool(tp+"check_now", sdk.ToolDef{
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleCheckNow)
pollMin := int(getSetting(s.Settings(), "poll_interval", int64(30)))
if pollMin < 5 {
pollMin = 5
}
p.pollTicker = time.NewTicker(time.Duration(pollMin) * time.Minute)
p.wg.Add(1)
go p.pollLoop()
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
return nil
}
func (p *Plugin) Stop() error {
p.stopOnce.Do(func() { close(p.stopCh) })
p.pollTicker.Stop()
p.wg.Wait()
p.saveData()
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
func (p *Plugin) pollLoop() {
defer p.wg.Done()
p.checkAllFeeds()
for {
select {
case <-p.pollTicker.C:
p.checkAllFeeds()
case <-p.stopCh:
return
}
}
}
func (p *Plugin) checkAllFeeds() {
p.mu.RLock()
feeds := make([]FeedSub, len(p.feeds))
copy(feeds, p.feeds)
p.mu.RUnlock()
for _, feed := range feeds {
select {
case <-p.stopCh:
return
default:
}
p.checkFeed(feed)
}
}
func (p *Plugin) checkFeed(sub FeedSub) {
parsed, err := p.fp.ParseURL(sub.URL)
if err != nil {
return
}
title := parsed.Title
if title == "" {
title = sub.URL
}
var newArticles []*gofeed.Item
for _, item := range parsed.Items {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
guid = sub.URL + "|" + guid
p.mu.RLock()
seen := p.seenGUIDs[guid]
p.mu.RUnlock()
if !seen {
newArticles = append(newArticles, item)
}
}
if len(newArticles) == 0 {
return
}
now := time.Now()
toInject := make([]*gofeed.Item, 0, len(newArticles))
p.mu.Lock()
for _, item := range newArticles {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
key := sub.URL + "|" + guid
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
continue
}
p.injected[key] = now
p.seenGUIDs[key] = true
toInject = append(toInject, item)
}
p.mu.Unlock()
if len(toInject) == 0 {
return
}
var lines []string
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
for _, item := range toInject {
pubDate := ""
if item.PublishedParsed != nil {
pubDate = item.PublishedParsed.Format("01-02 15:04")
}
line := fmt.Sprintf(" • %s", item.Title)
if pubDate != "" {
line += fmt.Sprintf(" [%s]", pubDate)
}
if item.Link != "" {
line += "\n " + item.Link
}
lines = append(lines, line)
}
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
p.saveData()
}
func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, error) {
url := readArg(args, "url")
if url == "" {
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
}
p.mu.RLock()
for _, f := range p.feeds {
if f.URL == url {
p.mu.RUnlock()
return map[string]interface{}{"isError": true, "content": "Already subscribed to: " + url}, nil
}
}
p.mu.RUnlock()
interval := readArgInt(args, "interval", 30)
if interval < 5 {
interval = 5
}
parsed, err := p.fp.ParseURL(url)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Failed to parse feed: " + err.Error()}, nil
}
feedTitle := parsed.Title
if feedTitle == "" {
feedTitle = url
}
sub := FeedSub{
URL: url,
Title: feedTitle,
AddedAt: time.Now().Format("2006-01-02 15:04"),
Interval: interval,
}
guidCount := 0
p.mu.Lock()
for _, item := range parsed.Items {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
p.seenGUIDs[url+"|"+guid] = true
guidCount++
}
p.mu.Unlock()
p.mu.Lock()
p.feeds = append(p.feeds, sub)
p.mu.Unlock()
p.saveData()
return map[string]interface{}{
"content": fmt.Sprintf("Subscribed to: %s\nTitle: %s\nArticles found: %d\nPoll interval: %d min", url, feedTitle, guidCount, interval),
}, nil
}
func (p *Plugin) handleUnsubscribe(args map[string]interface{}) (interface{}, error) {
url := readArg(args, "url")
if url == "" {
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
}
p.mu.Lock()
found := false
for i, f := range p.feeds {
if f.URL == url {
p.feeds = append(p.feeds[:i], p.feeds[i+1:]...)
found = true
break
}
}
if !found {
p.mu.Unlock()
return map[string]interface{}{"isError": true, "content": "Not subscribed to: " + url}, nil
}
for guid := range p.seenGUIDs {
if strings.HasPrefix(guid, url+"|") {
delete(p.seenGUIDs, guid)
}
}
p.mu.Unlock()
p.saveData()
return map[string]interface{}{"content": "Unsubscribed: " + url}, nil
}
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
p.mu.RLock()
defer p.mu.RUnlock()
if len(p.feeds) == 0 {
return map[string]interface{}{"content": "No subscriptions. Use rss_subscribe to add one."}, nil
}
sort.Slice(p.feeds, func(i, j int) bool {
return p.feeds[i].Title < p.feeds[j].Title
})
var lines []string
lines = append(lines, fmt.Sprintf("📡 Subscriptions (%d):", len(p.feeds)))
for _, f := range p.feeds {
lines = append(lines, fmt.Sprintf(" • %s\n %s (every %dm, added %s)", f.Title, f.URL, f.Interval, f.AddedAt))
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
select {
case <-p.stopCh:
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
default:
}
p.wg.Add(1)
go func() {
defer p.wg.Done()
p.checkAllFeeds()
}()
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
}
func (p *Plugin) dataFile() string {
return filepath.Join(p.dataDir, "feeds.json")
}
func (p *Plugin) loadData() {
b, err := os.ReadFile(p.dataFile())
if err != nil {
return
}
var data struct {
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}
if json.Unmarshal(b, &data) != nil {
return
}
if data.Feeds != nil {
p.feeds = data.Feeds
}
if data.SeenGUIDs != nil {
p.seenGUIDs = data.SeenGUIDs
}
}
func (p *Plugin) saveData() {
p.mu.RLock()
defer p.mu.RUnlock()
data := struct {
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}{
Feeds: p.feeds,
SeenGUIDs: p.seenGUIDs,
}
b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.dataFile(), b, 0644)
}
// cleanupData 卸载时清理订阅数据目录feeds.json 等)
func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if p.dataDir == "" {
return
}
for _, f := range []string{"feeds.json"} {
path := filepath.Join(p.dataDir, f)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
}
}
}

View File

@ -2,6 +2,6 @@ module sanitizer
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/sanitizer/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -7,5 +7,9 @@
"author": "HomeAgent SDK",
"entry": "plugin.so",
"tags": ["sanitizer"],
"targets": "linux/amd64"
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,5 +1,13 @@
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
// 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
// 同时保留原有"工具调用残留(思维泄漏)"清理。
//
// 挂载阶段:
// - StageOnInput : 清洗用户输入RawMessage
// - StageAfterToolcall : 清洗工具执行结果ToolResults坏字节不进 LLM 上下文
// - StagePostAction : 清洗 LLM 输出LLMText保留原有思维泄漏清理
//
// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
//
// 编译:
//
@ -13,21 +21,24 @@ import (
"log"
"regexp"
"strings"
"unicode/utf8"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
var (
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[:][^}]*\}\}`)
atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`)
)
type Plugin struct{}
@ -36,10 +47,41 @@ func (p *Plugin) Name() string { return "sanitizer" }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
// 1) 输入清洗
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
ctx.Lock()
before := ctx.RawMessage
ctx.RawMessage = cleanText(ctx.RawMessage)
if before != ctx.RawMessage {
log.Printf("[sanitizer] StageOnInput: cleaned %d bytes", len(before)-len(ctx.RawMessage))
}
ctx.Unlock()
return nil
})
// 2) 工具结果清洗(坏字节/ANSI 不得进 LLM 上下文)
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
for i, tr := range ctx.ToolResults {
if s, ok := tr.Result.(string); ok {
clean := cleanText(s)
if clean != s {
ctx.ToolResults[i].Result = clean
log.Printf("[sanitizer] StageAfterToolcall: tool=%s cleaned %d bytes", tr.Name, len(s)-len(clean))
}
}
}
return nil
})
// 3) LLM 输出清洗(保留原有思维泄漏清理 + 新增乱码清洗)
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
ctx.Lock()
before := len(ctx.LLMText)
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
ctx.LLMText = cleanText(ctx.LLMText)
after := len(ctx.LLMText)
ctx.Unlock()
if before != after {
@ -47,16 +89,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
return nil
})
log.Printf("[sanitizer] stage PostAction registered")
log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
return nil
}
func (p *Plugin) Stop() error { return nil }
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{}, nil
}
// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
func cleanToolCallLeakage(content string) string {
if content == "" {
return content
@ -83,8 +126,12 @@ func cleanToolCallLeakage(content string) string {
cleaned = append(cleaned, line)
continue
}
if toolNameRE.MatchString(trimmed) {
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") {
if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) {
continue
}
if m := toolNameRE.FindStringIndex(trimmed); m != nil {
rest := trimmed[m[1]:]
if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") {
continue
}
}
@ -100,3 +147,72 @@ func cleanToolCallLeakage(content string) string {
}
return content
}
// cleanText 清洗可能污染 LLM 上下文/输出的文本:
// 1. 剥离 ANSI 转义序列(\x1b[...m 等,源自终端输出)
// 2. 剔除无效 UTF-8 字节strings.ToValidUTF8 语义)与已解码的 U+FFFD 替换符,
// 避免模型复读坏字节/替换符造成乱码(把坏段落整体丢弃比留残字更干净)
func cleanText(s string) string {
if s == "" {
return s
}
// 先剥离 ANSI 转义ESC [ 参数 m / ESC ] 标题 / 其他 CSI 序列
if strings.ContainsRune(s, 0x1b) {
var sb strings.Builder
sb.Grow(len(s))
i := 0
for i < len(s) {
c := s[i]
if c == 0x1b {
// 跳过完整转义序列
j := i + 1
if j < len(s) {
switch s[j] {
case '[': // CSI: ESC [ <params> <letter>
j++
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
j++
}
if j < len(s) {
j++
}
i = j
continue
case ']': // OSC: ESC ] ... BEL / ST
i = j + 1
for i < len(s) && s[i] != 0x07 {
i++
}
i++ // skip BEL
continue
default: // 单字符转义ESC c ESC 7 等)
i = j + 1
continue
}
}
i++
continue
}
sb.WriteByte(c)
i++
}
s = sb.String()
}
// 剔除无效 UTF-8 与 U+FFFD 替换符
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "")
}
if strings.ContainsRune(s, utf8.RuneError) {
// 连 U+FFFD 也不留给模型复述
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r != utf8.RuneError {
b.WriteRune(r)
}
}
s = b.String()
}
return s
}

View File

@ -2,6 +2,31 @@ package main
import "testing"
func TestCleanText(t *testing.T) {
tests := []struct {
name, input, want string
}{
{"empty", "", ""},
{"clean", "你好世界 hello", "你好世界 hello"},
{"invalid_utf8", "a\xff\xfe b", "a b"},
{"ufffd", "有乱码\ufffd字符", "有乱码字符"},
{"multiple_ufffd", "a\ufffd\ufffdb\ufffdc", "abc"},
{"ansi_color", "\x1b[31m红色\x1b[0m结束", "红色结束"},
{"ansi_cursor", "a\x1b[2K\r\nb", "a\r\nb"},
{"ansi_osc", "\x1b]0;title\x07文本", "文本"},
{"an_and_ufffd", "\x1b[31m\ufffd中文\x1b[0m", "中文"},
{"emoji_kept", "颜文字(・ω・´)和🍎", "颜文字(・ω・´)和🍎"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cleanText(tt.input)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCleanToolCallLeakage(t *testing.T) {
tests := []struct {
name, input, want string
@ -28,4 +53,4 @@ func TestCleanToolCallLeakage(t *testing.T) {
}
})
}
}
}

7
example/vanblog/go.mod Normal file
View File

@ -0,0 +1,7 @@
module vanblog-plugin
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/vanblog/plg.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "vanblog",
"name_zh": "VanBlog 博客管理",
"name_en": "VanBlog",
"version": "1.0.0",
"description": "管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["blog", "vanblog", "cms"],
"targets": "linux/amd64"
}

1334
example/vanblog/plugin.go Normal file

File diff suppressed because it is too large Load Diff

13
example/weather/README.md Normal file
View File

@ -0,0 +1,13 @@
# weather
weather plugin
## Build
```bash
plugindev build
```
## Install
Upload the .hmap file through the Plugin Manager API.

8
example/weather/go.mod Normal file
View File

@ -0,0 +1,8 @@
module weather
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

16
example/weather/plg.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "weather",
"name_zh": "天气查询",
"name_en": "Weather",
"version": "1.0.0",
"description": "天气查询插件(基于 wttr.in支持实时天气和未来预报",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["weather", "forecast", "wttr"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

412
example/weather/plugin.go Normal file
View File

@ -0,0 +1,412 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
defaultLoc string
}
func NewPluginFactory(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 {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 15 * time.Second}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_location", Default: "", Type: "string",
DisplayName: "Default Location", Description: "Default city name for weather queries, e.g. Beijing",
Category: "weather",
})
if v, _ := s.Settings().Get("default_location"); v != nil {
if vs, ok := v.(string); ok {
p.defaultLoc = vs
}
}
tp := p.name + "_"
s.RegisterTool(tp+"current", sdk.ToolDef{
Name: tp + "current", Description: "Get current weather for a city",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string", "description": "City name (e.g. Beijing, Shanghai, London). Uses default if omitted."},
"units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"},
},
},
// NoMemory: 外部实时数据对记忆计算无长期价值,跳过向量化/关键词提取
NoMemory: true,
// Cleaner: 工具输出参与记忆计算前先过滤;这里演示用法(保留摘要行)
Cleaner: func(output string) string {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "🌤") {
return line
}
}
return output
},
}, p.handleCurrent)
s.RegisterTool(tp+"forecast", sdk.ToolDef{
Name: tp + "forecast", Description: "Get weather forecast for next several days",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string", "description": "City name. Uses default if omitted."},
"days": map[string]interface{}{"type": "integer", "description": "Number of days (1-7), default 3"},
"units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"},
},
},
NoMemory: true,
}, p.handleForecast)
s.RegisterTool(tp+"set_location", sdk.ToolDef{
Name: tp + "set_location", Description: "Set default weather location",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string", "description": "City name to set as default"},
},
"required": []string{"location"},
},
NoMemory: true,
}, p.handleSetLocation)
// 阶段钩子own_tools 作用域——仅在本插件的工具被调用时触发
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
if len(ctx.ToolResults) > 0 {
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
}
return nil
}, sdk.StageScopeOwnTools)
// 输出通道:把天气结果主动推给用户(如 QQ/WebUI 渠道)
if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{
NoMemory: true,
}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
return map[string]interface{}{"content": "weather pushed: " + payload}, nil
}); err != nil {
return err
}
// 输入通道接收天气订阅请求NoMemory: 通道输入不参与记忆计算)
if err := s.RegisterInputChannel(tp+"weather_in", sdk.ChannelDef{NoMemory: true}); err != nil {
return err
}
fmt.Printf("[%s] started\n", p.name)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
type wttrResp struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
FeelsLikeC string `json:"FeelsLikeC"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
LocalObsDateTime string `json:"localObsDateTime"`
} `json:"current_condition"`
NearestArea []struct {
AreaName []struct {
Value string `json:"value"`
} `json:"areaName"`
Country []struct {
Value string `json:"value"`
} `json:"country"`
Region []struct {
Value string `json:"value"`
} `json:"region"`
} `json:"nearest_area"`
Weather []wttrDay `json:"weather"`
}
type wttrDay struct {
Date string `json:"date"`
Astronomy []struct {
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
} `json:"astronomy"`
MaxtempC string `json:"maxtempC"`
MintempC string `json:"mintempC"`
Hourly []struct {
TempC string `json:"tempC"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Humidity string `json:"humidity"`
FeelsLikeC string `json:"FeelsLikeC"`
PrecipMM string `json:"precipMM"`
Visibility string `json:"visibility"`
} `json:"hourly"`
}
func (p *Plugin) getLoc(args map[string]interface{}) string {
if v, ok := args["location"].(string); ok && v != "" {
return v
}
return p.defaultLoc
}
func (p *Plugin) getUnits(args map[string]interface{}) string {
if v, ok := args["units"].(string); ok && (v == "imperial" || v == "metric") {
return v
}
return "metric"
}
func (p *Plugin) fetchWttr(location string) (*wttrResp, error) {
url := fmt.Sprintf("https://wttr.in/%s?format=j1", strings.ReplaceAll(location, " ", "%20"))
resp, err := p.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var data wttrResp
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
if len(data.CurrentCondition) == 0 {
return nil, fmt.Errorf("no weather data for: %s", location)
}
return &data, nil
}
func (p *Plugin) displayName(data *wttrResp) string {
if len(data.NearestArea) == 0 {
return "Unknown"
}
area := data.NearestArea[0]
name := ""
if len(area.AreaName) > 0 {
name = area.AreaName[0].Value
}
region := ""
if len(area.Region) > 0 {
region = area.Region[0].Value
}
country := ""
if len(area.Country) > 0 {
country = area.Country[0].Value
}
var parts []string
if name != "" {
parts = append(parts, name)
}
if region != "" && region != name {
parts = append(parts, region)
}
if country != "" {
parts = append(parts, country)
}
return strings.Join(parts, ", ")
}
func convertCtoF(c string) string {
if v, err := strconv.ParseFloat(c, 64); err == nil {
return fmt.Sprintf("%.0f", v*9/5+32)
}
return c
}
func (p *Plugin) handleCurrent(args map[string]interface{}) (interface{}, error) {
location := p.getLoc(args)
if location == "" {
return map[string]interface{}{"isError": true, "content": "No location specified. Provide a city name or set default_location."}, nil
}
units := p.getUnits(args)
data, err := p.fetchWttr(location)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Weather request failed: " + err.Error()}, nil
}
cc := data.CurrentCondition[0]
place := p.displayName(data)
desc := ""
if len(cc.WeatherDesc) > 0 {
desc = cc.WeatherDesc[0].Value
}
unitStr := "°C"
windUnit := "km/h"
tempStr := cc.TempC
feelsStr := cc.FeelsLikeC
if units == "imperial" {
unitStr = "°F"
windUnit = "mph"
tempStr = convertCtoF(tempStr)
feelsStr = convertCtoF(feelsStr)
}
obsTime := cc.LocalObsDateTime
if len(obsTime) > 16 {
obsTime = obsTime[:16]
}
result := fmt.Sprintf("🌤 %s — %s\n🌡 %s%s (体感 %s%s)\n💧 湿度 %s%% | 💨 风速 %s %s %s\n🕐 %s",
place, desc,
tempStr, unitStr, feelsStr, unitStr,
cc.Humidity, cc.WindspeedKmph, windUnit, cc.Winddir16Point,
obsTime)
return map[string]interface{}{
"content": result,
"location": place,
"temp": cc.TempC,
"feels_like": cc.FeelsLikeC,
"humidity": cc.Humidity,
"wind_speed": cc.WindspeedKmph,
"weather": desc,
"observed": obsTime,
}, nil
}
func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error) {
location := p.getLoc(args)
if location == "" {
return map[string]interface{}{"isError": true, "content": "No location specified."}, nil
}
days := 3
if v, ok := args["days"].(float64); ok {
d := int(v)
if d >= 1 && d <= 7 {
days = d
}
}
units := p.getUnits(args)
data, err := p.fetchWttr(location)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Forecast request failed: " + err.Error()}, nil
}
place := p.displayName(data)
unitStr := "°C"
if units == "imperial" {
unitStr = "°F"
}
dayCount := days
if dayCount > len(data.Weather) {
dayCount = len(data.Weather)
}
daysData := data.Weather[:dayCount]
var lines []string
lines = append(lines, fmt.Sprintf("📅 %d日天气预报 — %s", days, place))
for _, day := range daysData {
t, err := time.Parse("2006-01-02", day.Date)
if err != nil {
continue
}
weekday := t.Weekday().String()[:3]
maxT := day.MaxtempC
minT := day.MintempC
desc := ""
precip := ""
if len(day.Hourly) > 0 {
mid := len(day.Hourly) / 2
if len(day.Hourly[mid].WeatherDesc) > 0 {
desc = day.Hourly[mid].WeatherDesc[0].Value
}
totalPrecip := 0.0
for _, h := range day.Hourly {
if pv, err := strconv.ParseFloat(h.PrecipMM, 64); err == nil {
totalPrecip += pv
}
}
if totalPrecip > 0 {
precip = fmt.Sprintf(" 🌧%.1fmm", totalPrecip)
}
}
if units == "imperial" {
maxT = convertCtoF(maxT)
minT = convertCtoF(minT)
}
sunrise, sunset := "", ""
if len(day.Astronomy) > 0 {
sunrise = day.Astronomy[0].Sunrise
sunset = day.Astronomy[0].Sunset
}
datePart := ""
if len(day.Date) >= 8 {
datePart = day.Date[5:7] + "/" + day.Date[8:]
}
line := fmt.Sprintf(" %s %s — %s~%s%s %s", weekday, datePart, minT, maxT, unitStr, desc)
if precip != "" {
line += precip
}
if sunrise != "" && sunset != "" {
line += fmt.Sprintf(" 🌅%s 🌇%s", sunrise, sunset)
}
lines = append(lines, line)
}
cc := data.CurrentCondition[0]
nowDesc := ""
if len(cc.WeatherDesc) > 0 {
nowDesc = cc.WeatherDesc[0].Value
}
lines = append(lines, fmt.Sprintf("\n当前%s %s°C", nowDesc, cc.TempC))
return map[string]interface{}{
"content": strings.Join(lines, "\n"),
"location": place,
}, nil
}
func (p *Plugin) handleSetLocation(args map[string]interface{}) (interface{}, error) {
loc, _ := args["location"].(string)
if loc == "" {
return map[string]interface{}{"isError": true, "content": "Location is required"}, nil
}
p.sdk.Settings().Set("default_location", loc)
p.defaultLoc = loc
return map[string]interface{}{"content": fmt.Sprintf("Default location set to: %s", loc)}, nil
}

2
go.mod
View File

@ -1,3 +1,3 @@
module gitcode.com/JianFeeeee/homeagent-sdk
go 1.25.0
go 1.21.0

View File

@ -1,11 +1,12 @@
// Package meta 收集 HomeAgent SDK 的全部元数据。
// 版本号应与核心 meta.Version 保持一致。
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
package meta
var (
// Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
Version = "0.7.1"
Version = "0.9.1"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -15,9 +16,92 @@ var (
// SDKName 是 SDK 名称。
SDKName = "HomeAgent SDK"
// CoreModule 是核心仓的 Go module path供 plugindev 生成 go.mod 时使用。
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。
CoreVersion = "0.9.1"
)
// FullVersion 返回完整的版本字符串。
func FullVersion() string {
return SDKName + " v" + Version + " (" + Commit + ")"
}
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
// ABI 标识版本直接取内核版本号字符串semver与核心 Version 保持一致,不使用独立数字编码。
// 协商层C 结构体 int version 字段)使用 CABINum由版本字符串派生的整数major*100 + minor
// 映射v0.8.x → CABINum=800v0.9.x → CABINum=900invoke_stage 写回)。
// 小版本patch演进不影响 ABICABINum 不变。version_min 保证旧 ABI 插件仍可加载。
var (
// ABIVersion 是 ABI 标识版本(字符串 semver与 SDK CoreVersion 对齐)。
ABIVersion = CoreVersion
// ABIVersionMin 是兼容的最低 ABI 标识版本。
ABIVersionMin = "0.8.0"
)
const (
// CABINum 是 C 层协商用的整数版本major*100 + minor随 ABIVersion 派生。
CABINum = 900
// CABINumMin 是 C 层兼容的最低整数版本。
// 旧工具链v0.8 之前)写入的整数 version=1无写回能力但与新内核结构兼容
// 因此最小值保持 1 以兼容全部旧插件(新插件 900 匹配,旧插件 1/2 通过);
// 仅当未来内核 ABI 破坏兼容时才提高该值。
CABINumMin = 1
)
// ---- Dispatch Method IDs与核心仓 internal/meta/meta.go 同步) ----
const (
CoreRegisterTool = 1
CoreRegisterStage = 2
CoreRegisterOutputCh = 3
CoreRegisterPluginAPI = 4
CoreInjectText = 5
CoreInjectInterruptText = 6
CoreInjectTextNoMemory = 7
CoreSetAutoRestart = 8
CoreMemoryRecall = 9
CoreMemoryCommit = 10
CoreMemoryIntrospect = 11
CoreMemoryMerge = 12
CoreMemoryPurge = 13
CoreDocQuery = 14
CoreKnowledgeSearch = 15
CoreSettingsGet = 16
CoreSettingsSet = 17
CoreSettingsRegisterDef = 18
CoreLLMListSources = 19
CoreLLMSetSource = 20
CoreSocialGetPerson = 21
CoreSocialGetNetwork = 22
CoreSubscribe = 23
CoreUnsubscribe = 24
CoreFreeString = 25
CoreSettingsGetCore = 26
CoreSettingsSetCore = 27
CoreSettingsListCore = 28
CoreSettingsGetPlugin = 29
CoreSettingsSetPlugin = 30
CoreSettingsListPlugin = 31
CoreDocInsert = 32
CoreDocRemove = 33
CoreDocStats = 34
CoreKnowledgeAdd = 35
CoreKnowledgeList = 36
CoreLLMCurrentSource = 37
CoreSocialGetTrait = 38
CoreSocialGetRelations = 39
CoreSocialListPersons = 40
CoreTextMemoryAppend = 41
CoreSettingsList = 42
CoreSettingsDefs = 43
CoreSettingsDump = 44
CoreSettingsPlugins = 45
CoreRegisterInputCh = 46
CoreInjectInputSync = 47
CorePluginReloadOne = 48
CorePluginListLoaded = 49
CorePluginIsDisabled = 50
)

63
package/build.sh Executable file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="${PROJECT_ROOT}/build"
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
GOCACHE="${GOCACHE:-}"
GOPATH="${GOPATH:-}"
TARGET="${1:-native}"
COMPONENT="${2:-all}"
case "$TARGET" in
native) GOOS="" GOARCH="" ;;
linux/amd64) GOOS=linux GOARCH=amd64 ;;
linux/arm64) GOOS=linux GOARCH=arm64 ;;
darwin/amd64) GOOS=darwin GOARCH=amd64 ;;
darwin/arm64) GOOS=darwin GOARCH=arm64 ;;
windows/amd64) GOOS=windows GOARCH=amd64 ;;
all)
"$0" linux/amd64 "$COMPONENT"
"$0" linux/arm64 "$COMPONENT"
"$0" darwin/amd64 "$COMPONENT"
"$0" darwin/arm64 "$COMPONENT"
"$0" windows/amd64 "$COMPONENT"
exit 0
;;
*)
echo "Unknown target: $TARGET"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|plugindev]"
exit 1
esac
if [ -n "${GOOS:-}" ]; then
SUFFIX="${GOOS}_${GOARCH}"
export GOOS GOARCH
fi
export CGO_ENABLED=0
[ -n "$GOCACHE" ] && export GOCACHE
[ -n "$GOPATH" ] && export GOPATH
mkdir -p "$BUILD_DIR"
build_plugindev() {
local src="tools/plugindev"
local out="$BUILD_DIR/plugindev${SUFFIX:+_$SUFFIX}"
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] plugindev ${GOOS:-linux}/${GOARCH:-amd64}$out"
cd "$PROJECT_ROOT/$src"
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
-o "$out" .
echo " OK ($(du -h "$out" | cut -f1))"
cd "$PROJECT_ROOT"
}
case "$COMPONENT" in
all|plugindev) build_plugindev ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
esac

137
package/toolchain.nsi Normal file
View File

@ -0,0 +1,137 @@
!include "MUI2.nsh"
!include "nsDialogs.nsh"
!include "LogicLib.nsh"
!include "x64.nsh"
!include "WinVer.nsh"
!define PRODUCT_NAME "HomeAgent Toolchain"
!define PRODUCT_PUBLISHER "HomeAgent Team"
!define PRODUCT_VERSION "0.7.1"
!define PRODUCT_DISPLAY_NAME "HomeAgent 工具链"
!define OUTPUT_FILE "HomeAgent_v${PRODUCT_VERSION}_Toolchain_win64.exe"
!define SDK_VERSION "v0.7.1"
Name "${PRODUCT_DISPLAY_NAME} v${PRODUCT_VERSION}"
OutFile "${OUTPUT_FILE}"
InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}"
InstallDirRegKey HKLM "Software\${PRODUCT_NAME}" ""
RequestExecutionLevel admin
BrandingText "HomeAgent Toolchain Installer"
SetCompressor /SOLID lzma
ShowInstDetails show
ShowUninstDetails show
Var hasGit
Var sdkInstallOk
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
Page custom pageConfirm pageConfirmLeave
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "English"
Function .onInit
!insertmacro MUI_LANGDLL_DISPLAY
StrCpy $hasGit "0"
StrCpy $sdkInstallOk "0"
FunctionEnd
Function pageConfirm
!insertmacro MUI_HEADER_TEXT "确认安装" "将安装 HomeAgent 工具链并自动下载 SDK ${SDK_VERSION}"
nsDialogs::Create 1018
Pop $0
${If} $0 == error
Abort
${EndIf}
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
Pop $0
${NSD_CreateLabel} 15u 20u 100% 12u "• plugindev.exe — 插件开发工具"
Pop $0
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
Pop $0
${NSD_CreateLabel} 0 60u 100% 20u "SDK 需要 Git 客户端。如果未安装 Git请先安装:$\r$\nhttps://git-scm.com/downloads"
Pop $0
nsDialogs::Show
FunctionEnd
Function pageConfirmLeave
FunctionEnd
Section "Install" SEC_INSTALL
SetOutPath "$INSTDIR"
DetailPrint "复制工具链文件..."
File "plugindev.exe"
DetailPrint "创建快捷方式..."
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk" "$INSTDIR\plugindev.exe" "" "$INSTDIR\plugindev.exe" 0
DetailPrint "配置环境变量..."
; Add to system PATH
ReadRegStr $0 HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PATH"
${If} $0 != ""
${If} $0 != "*$INSTDIR*"
StrCpy $0 "$0;$INSTDIR"
WriteRegStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PATH" $0
${EndIf}
${Else}
WriteRegStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PATH" "$INSTDIR"
${EndIf}
WriteRegStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR" "$INSTDIR\sdk"
WriteRegStr HKLM "Software\${PRODUCT_NAME}" "" "$INSTDIR"
DetailPrint "检测 Git 客户端..."
nsExec::ExecToStack '"git" --version'
Pop $0
Pop $1
${If} $0 == 0
StrCpy $hasGit "1"
DetailPrint "Git 已安装: $1"
${Else}
DetailPrint "未检测到 Git将跳过 SDK 自动下载"
DetailPrint "安装完成后请手动运行: plugindev sdk install ${SDK_VERSION}"
${EndIf}
${If} $hasGit == "1"
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
nsExec::ExecToStack '"$INSTDIR\plugindev.exe" sdk install ${SDK_VERSION}'
Pop $0
Pop $1
${If} $0 == 0
StrCpy $sdkInstallOk "1"
DetailPrint "SDK ${SDK_VERSION} 下载完成"
DetailPrint "正在激活 SDK ${SDK_VERSION}..."
nsExec::Exec '"$INSTDIR\plugindev.exe" sdk use ${SDK_VERSION}'
Pop $0
${Else}
DetailPrint "SDK 下载失败 (错误码: $0)"
DetailPrint "请手动运行: plugindev sdk install ${SDK_VERSION}"
${EndIf}
${EndIf}
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_DISPLAY_NAME} v${PRODUCT_VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" "$INSTDIR\Uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayVersion" "${PRODUCT_VERSION}"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoRepair" 1
WriteUninstaller "$INSTDIR\Uninstall.exe"
SectionEnd
Section "Uninstall"
Delete "$INSTDIR\Uninstall.exe"
Delete "$INSTDIR\plugindev.exe"
RMDir /r "$INSTDIR\sdk"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk"
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
DeleteRegKey HKLM "Software\${PRODUCT_NAME}"
SectionEnd

116
remotedevice/CMakeLists.txt Normal file
View File

@ -0,0 +1,116 @@
cmake_minimum_required(VERSION 3.10)
project(ha_remotedevice VERSION 0.1.0 LANGUAGES C)
# ============================================================
# ha_remotedevice — HomeAgent 远程设备接入 C SDK
# 零外部依赖,纯 C 实现,兼容嵌入式平台。
#
# 使用方式:
# add_subdirectory(path/to/ha_remotedevice)
# target_link_libraries(my_app ha_remotedevice)
# target_include_directories(my_app PRIVATE
# ${HA_REMOTEDEVICE_INCLUDE_DIR})
# ============================================================
# 选项: 构建为静态库或动态库
option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF)
# 选项: 禁用 malloc/free用于裸机环境用户需提供 alloc 回调)
option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF)
# 选项: 日志级别
set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug")
# 源文件
set(HA_REMOTEDEVICE_SRC
src/ha_remotedevice.c
src/ha_json.c
src/ha_ws.c
)
# 头文件
set(HA_REMOTEDEVICE_INCLUDE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
# 编译选项
if(HA_NO_ALLOC)
add_definitions(-DHA_NO_ALLOC)
endif()
add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL})
# 创建库
if(BUILD_SHARED_LIBS)
add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC})
if(WIN32)
# Windows 需要导出符号
set_target_properties(ha_remotedevice PROPERTIES
WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
else()
add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC})
endif()
# 包含目录
target_include_directories(ha_remotedevice
PUBLIC ${HA_REMOTEDEVICE_INCLUDE}
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
)
# 不链接任何外部库
target_link_libraries(ha_remotedevice PRIVATE)
# 导出包含目录供外部项目使用
set(HA_REMOTEDEVICE_INCLUDE_DIR
${HA_REMOTEDEVICE_INCLUDE}
CACHE INTERNAL "ha_remotedevice include directories")
# 安装规则
install(TARGETS ha_remotedevice
EXPORT ha_remotedevice-targets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include
)
install(DIRECTORY include/
DESTINATION include
)
install(EXPORT ha_remotedevice-targets
DESTINATION lib/cmake/ha_remotedevice
NAMESPACE ha_remotedevice::
)
# ============================================================
# 测试(可选)
# ============================================================
option(BUILD_TESTS "Build ha_remotedevice tests" OFF)
if(BUILD_TESTS)
find_package(Threads REQUIRED)
add_executable(ha_remotedevice_test
test/test_ha_remotedevice.c
)
target_link_libraries(ha_remotedevice_test
PRIVATE ha_remotedevice Threads::Threads
)
target_include_directories(ha_remotedevice_test
PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR}
)
# 添加测试
add_test(NAME ha_remotedevice_test
COMMAND ha_remotedevice_test
)
endif()
# ============================================================
# 编译信息
# ============================================================
message(STATUS "ha_remotedevice ${PROJECT_VERSION}")
message(STATUS " Build type: $<CONFIG>")
message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}")
message(STATUS " No alloc: ${HA_NO_ALLOC}")

View File

@ -0,0 +1,216 @@
#ifndef HA_REMOTEDEVICE_H
#define HA_REMOTEDEVICE_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ==================================================================
* ha_remotedevice — 远程设备接入 C SDK
*
* 零外部依赖,纯 C 实现,兼容嵌入式平台。
* 传输层由用户实现4 个函数指针SDK 处理所有协议细节。
*
* 声明式设计:
* 设备在代码中声明自己是什么(kind)和能做什么(caps)
* 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数,
* SDK 自动处理协议握手、心跳、消息路由、结果回执。
*
* 协议流程:
* TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪
* 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果
* ================================================================== */
/* ======================== 状态码 ======================== */
typedef enum {
HA_OK = 0,
HA_ERR_GENERIC = -1,
HA_ERR_NOMEM = -2,
HA_ERR_INVALID = -3,
HA_ERR_TIMEOUT = -4,
HA_ERR_DISCONNECTED = -5,
HA_ERR_PROTOCOL = -6,
HA_ERR_TRANSPORT = -7,
HA_ERR_NOT_FOUND = -8,
} ha_status_t;
/* ======================== 传输层抽象 ========================
*
* 用户必须实现这 4 个函数适配不同平台FreeRTOS+lwIP、Zephyr、裸机等
*
* connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功
* send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败
* recv(ctx, buf, len) → 接收最多 len 字节返回实际接收字节数0 断开,-1 失败
* close(ctx) → 关闭连接
*/
typedef struct {
int (*connect)(void *ctx, const char *host, uint16_t port);
int (*send)(void *ctx, const uint8_t *data, int len);
int (*recv)(void *ctx, uint8_t *buf, int len);
void (*close)(void *ctx);
void *ctx;
} ha_transport_t;
/* ======================== 设备声明 ========================
*
* 声明式配置:设备在代码中声明自己的类型和能力。
* 这些信息通过 hello 消息发送给网关。
*
* device_id — 唯一标识,如 "esp32-cam-1"
* name — 设备显示名,如 "门口摄像头"
* kind — 设备种类,如 "camera"、"computer"、"speaker"、"light"
* caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL}
* info_json — 额外信息JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}'
*/
typedef struct {
const char *device_id;
const char *name;
const char *kind;
const char **caps; /* NULL 结尾 */
const char *info_json; /* 可选NULL 或 JSON 字符串 */
} ha_device_info_t;
/* ======================== 命令结果 ========================
*
* 命令处理函数通过填写此结构体返回数据。
* SDK 收到结果后自动发送回执(文本或二进制分块)。
*
* 使用方式:
* 1. 简单文本:设置 status=0, output="结果文本"
* 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime
* 3. 错误:设置 status=1, error="错误信息"
*
* 注意output 字符串由 SDK 内部 strdup 后发送handler 返回后即可释放。
* 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。
* 所以 handler 可以返回栈上或静态字符串。
*/
typedef struct {
int status; /* 0=ok, 非0=error */
const char *output; /* 输出文本(如 base64 图像数据SDK 内部拷贝 */
const char *error; /* 错误信息 */
int has_binary; /* 1=通过二进制分块回传 */
const char *binary_mime; /* 二进制 MIME 类型 */
const uint8_t *binary_data; /* 二进制数据指针 */
int binary_len; /* 二进制数据长度 */
} ha_cmd_result_t;
/* ======================== 命令处理声明 ========================
*
* 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。
*
* command 值说明:
* - "shell" → 处理 shell 类型命令args 为完整命令字符串
* - "camerasue" → 处理 homeagent-camerasue 命令args 为参数
* - "screensee" → 处理 homeagent-screensee 命令
* - "speakeruse" → 处理 homeagent-speakeruse 命令
* - "computeruse" → 处理 homeagent-computeruse 命令
* - "clipboardsee" → 处理 homeagent-clipboardsee 命令
* - "clipboardsue" → 处理 homeagent-clipboardsue 命令
* - "screensue" → 处理 homeagent-screensue 命令
* - "deviceinfo" → 处理设备信息查询
* - 其他自定义命令名 → 按字符串匹配分发
*
* handler 处理完毕后只需填写 result 结构体SDK 自动回执。
*/
typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata);
typedef struct {
const char *command; /* 命令名,如 "camerasue"、"shell" */
ha_cmd_handler_t handler; /* 处理函数 */
} ha_cmd_handler_def_t;
/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。
* data 指针在回调返回后失效,如需保存请拷贝。 */
typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind,
const char *mime, const uint8_t *data,
int len, void *userdata);
/* 连接状态变化回调 */
typedef void (*ha_state_callback_t)(int connected, void *userdata);
/* ======================== 客户端配置 ========================
*
* 所有配置在 ha_client_new() 时一次性声明。
* 声明式核心handlers 表声明了设备支持的所有命令及其处理函数。
*/
typedef struct {
ha_transport_t transport; /* 传输层实现(必须) */
ha_device_info_t device; /* 设备声明(必须) */
const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */
const char *token; /* 接入令牌(必须) */
ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */
ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */
ha_state_callback_t on_state; /* 状态变化回调(可选) */
void *userdata; /* 用户自定义数据,传给所有回调 */
int ping_interval; /* 心跳间隔秒数0 则默认 30 */
int max_reconnect; /* 最大重连次数,-1 无限重连默认0 不重连 */
} ha_config_t;
/* ======================== 客户端 API ======================== */
typedef struct ha_client ha_client_t;
/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */
ha_client_t *ha_client_new(const ha_config_t *config);
/* 启动连接TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */
ha_status_t ha_client_start(ha_client_t *client);
/* 主循环处理:必须在用户的主循环中周期性调用。
* - 读取 WS 帧并分发
* - 按 handlers 表查找命令处理函数,自动回执结果
* - 处理心跳 ping/pong
* - 处理断线重连
* 返回 HA_OK 表示正常HA_ERR_DISCONNECTED 表示正在重连。 */
ha_status_t ha_client_process(ha_client_t *client);
/* ===== 主动上报(设备主动推送,非命令响应) ===== */
/* 发送设备主动上报事件。type 如 "motion_detected"detail 为 JSON 字符串。 */
void ha_client_send_event(ha_client_t *client, const char *type,
const char *detail);
/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */
void ha_client_send_status(ha_client_t *client, const char *status);
/* ===== 生命周期 ===== */
/* 停止客户端,断开连接。 */
void ha_client_stop(ha_client_t *client);
/* 销毁客户端,释放所有资源。 */
void ha_client_destroy(ha_client_t *client);
/* ======================== 工具函数 ======================== */
/* 解析 homeagent-* 命令,返回能力名和参数。
* command = "camerasue 5" → cap="camerasue", args="5"
* command = "screensee" → cap="screensee", args=""
* command = "computeruse {...}" → cap="computeruse", args="..." */
void ha_cmd_parse_homeagent(const char *command, const char **cap,
const char **args);
/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。
* command = "computeruse {\"action\":\"click\",\"x\":100}"
* → action="computeruse", json_str="{\"action\":\"click\",...}" */
void ha_cmd_parse_json(const char *command, const char **action,
const char **json_str);
/* Base64 编码(用于将二进制数据编码为文本回传)。
* 返回写入 out 的字节数(不含 \0out 不足时返回所需长度。 */
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len);
/* 获取版本号 */
const char *ha_version(void);
#ifdef __cplusplus
}
#endif
#endif /* HA_REMOTEDEVICE_H */

369
remotedevice/src/ha_json.c Normal file
View File

@ -0,0 +1,369 @@
#include "ha_json.h"
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <stdio.h>
/* ======================== 解析器 ======================== */
/* 前向声明 */
static ha_json_node_t *parse_value(const char **pp);
/* 跳过空白 */
static const char *skip_ws(const char *p) {
while (*p && (unsigned char)*p <= ' ') p++;
return p;
}
/* 解析字符串("..."返回新分配的字符串p 更新到结束引号后 */
static char *parse_string(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '"') return NULL;
p++;
int len = 0;
const char *q = p;
while (*q && *q != '"') {
if (*q == '\\') { q++; if (*q) q++; }
else q++;
len++;
}
if (*q != '"') return NULL;
char *s = (char *)malloc(len + 1);
if (!s) return NULL;
q = p;
int i = 0;
while (*q && *q != '"') {
if (*q == '\\') {
q++;
switch (*q) {
case '"': s[i++] = '"'; break;
case '\\': s[i++] = '\\'; break;
case '/': s[i++] = '/'; break;
case 'b': s[i++] = '\b'; break;
case 'f': s[i++] = '\f'; break;
case 'n': s[i++] = '\n'; break;
case 'r': s[i++] = '\r'; break;
case 't': s[i++] = '\t'; break;
case 'u': q += 4; s[i++] = '?'; continue;
default: s[i++] = *q; break;
}
q++;
} else {
s[i++] = *q++;
}
}
s[i] = '\0';
*pp = q + 1;
return s;
}
static ha_json_node_t *new_node(ha_json_type_t type) {
ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t));
if (n) n->type = type;
return n;
}
/* 解析数字 */
static ha_json_node_t *parse_number(const char **pp) {
const char *p = *pp;
int neg = 0;
if (*p == '-') { neg = 1; p++; }
if (!isdigit((unsigned char)*p)) return NULL;
int val = 0;
while (isdigit((unsigned char)*p)) {
val = val * 10 + (*p - '0');
p++;
}
if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; }
if (*p == 'e' || *p == 'E') {
p++;
if (*p == '+' || *p == '-') p++;
while (isdigit((unsigned char)*p)) p++;
}
*pp = p;
ha_json_node_t *n = new_node(HA_JSON_INT);
if (n) n->int_val = neg ? -val : val;
return n;
}
/* 解析 true/false/null */
static ha_json_node_t *parse_keyword(const char **pp) {
const char *p = *pp;
ha_json_node_t *n = NULL;
if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) {
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1;
*pp = p + 4;
} else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) {
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0;
*pp = p + 5;
} else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) {
n = new_node(HA_JSON_NULL);
*pp = p + 4;
}
return n;
}
/* 解析对象 */
static ha_json_node_t *parse_object(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '{') return NULL;
p++;
ha_json_node_t *obj = new_node(HA_JSON_OBJECT);
if (!obj) return NULL;
ha_json_node_t **tail = &obj->child;
p = skip_ws(p);
if (*p == '}') { *pp = p + 1; return obj; }
while (*p) {
p = skip_ws(p);
char *key = parse_string(&p);
if (!key) break;
p = skip_ws(p);
if (*p != ':') { free(key); break; }
p++;
ha_json_node_t *val = parse_value(&p);
if (!val) { free(key); break; }
val->key = key;
*tail = val;
tail = &val->next;
p = skip_ws(p);
if (*p == ',') { p++; continue; }
if (*p == '}') break;
}
p = skip_ws(p);
if (*p == '}') { *pp = p + 1; return obj; }
ha_json_free(obj);
return NULL;
}
/* 解析数组 */
static ha_json_node_t *parse_array(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '[') return NULL;
p++;
ha_json_node_t *arr = new_node(HA_JSON_ARRAY);
if (!arr) return NULL;
ha_json_node_t **tail = &arr->child;
p = skip_ws(p);
if (*p == ']') { *pp = p + 1; return arr; }
while (*p) {
ha_json_node_t *val = parse_value(&p);
if (!val) break;
*tail = val;
tail = &val->next;
p = skip_ws(p);
if (*p == ',') { p++; continue; }
if (*p == ']') break;
}
p = skip_ws(p);
if (*p == ']') { *pp = p + 1; return arr; }
ha_json_free(arr);
return NULL;
}
/* 解析值(主入口) */
static ha_json_node_t *parse_value(const char **pp) {
const char *p = skip_ws(*pp);
if (*p == '{') return parse_object(pp);
if (*p == '[') return parse_array(pp);
if (*p == '"') {
char *s = parse_string(pp);
if (!s) return NULL;
ha_json_node_t *n = new_node(HA_JSON_STRING);
if (!n) { free(s); return NULL; }
n->str_val = s;
return n;
}
if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp);
return parse_keyword(pp);
}
/* ======================== 公共 API ======================== */
ha_json_node_t *ha_json_parse(const char *str) {
if (!str) return NULL;
const char *p = str;
return parse_value(&p);
}
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) {
ha_json_node_t *n = ha_json_get(obj, key);
if (!n || n->type != HA_JSON_STRING) return NULL;
return n->str_val;
}
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) {
ha_json_node_t *n = ha_json_get(obj, key);
if (!n || n->type != HA_JSON_INT) return def;
return n->int_val;
}
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) {
if (!obj || obj->type != HA_JSON_OBJECT) return NULL;
ha_json_node_t *c = obj->child;
while (c) {
if (c->key && strcmp(c->key, key) == 0) return c;
c = c->next;
}
return NULL;
}
int ha_json_array_len(const ha_json_node_t *arr) {
if (!arr || arr->type != HA_JSON_ARRAY) return 0;
int n = 0;
ha_json_node_t *c = arr->child;
while (c) { n++; c = c->next; }
return n;
}
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) {
if (!arr || arr->type != HA_JSON_ARRAY) return NULL;
ha_json_node_t *c = arr->child;
int i = 0;
while (c) {
if (i == index) return c;
i++; c = c->next;
}
return NULL;
}
void ha_json_free(ha_json_node_t *root) {
if (!root) return;
ha_json_node_t *c = root->child;
while (c) {
ha_json_node_t *next = c->next;
free(c->key);
if (c->type == HA_JSON_STRING) free(c->str_val);
ha_json_free(c);
c = next;
}
free(root);
}
/* ======================== 构建器 ======================== */
static void json_escape(ha_json_builder_t *jb, const char *s) {
if (!s) { ha_json_builder_raw(jb, "null"); return; }
ha_json_builder_raw(jb, "\"");
for (const char *p = s; *p; p++) {
unsigned char c = (unsigned char)*p;
switch (c) {
case '"': ha_json_builder_raw(jb, "\\\""); break;
case '\\': ha_json_builder_raw(jb, "\\\\"); break;
case '\b': ha_json_builder_raw(jb, "\\b"); break;
case '\f': ha_json_builder_raw(jb, "\\f"); break;
case '\n': ha_json_builder_raw(jb, "\\n"); break;
case '\r': ha_json_builder_raw(jb, "\\r"); break;
case '\t': ha_json_builder_raw(jb, "\\t"); break;
default:
if (c < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
ha_json_builder_raw(jb, buf);
} else {
char buf[2] = { (char)c, 0 };
ha_json_builder_raw(jb, buf);
}
break;
}
}
ha_json_builder_raw(jb, "\"");
}
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) {
jb->buf = buf;
jb->len = 0;
jb->cap = cap;
jb->depth = 0;
if (cap > 0) buf[0] = '\0';
}
void ha_json_builder_reset(ha_json_builder_t *jb) {
jb->len = 0;
jb->depth = 0;
if (jb->cap > 0) jb->buf[0] = '\0';
}
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) {
while (*s && jb->len < jb->cap - 1) {
jb->buf[jb->len++] = *s++;
}
jb->buf[jb->len] = '\0';
}
void ha_json_builder_comma(ha_json_builder_t *jb) {
if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) {
ha_json_builder_raw(jb, ",");
}
if (jb->depth > 0) jb->item_count[jb->depth - 1]++;
}
void ha_json_builder_begin_object(ha_json_builder_t *jb) {
ha_json_builder_comma(jb);
ha_json_builder_raw(jb, "{");
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
jb->depth++;
}
void ha_json_builder_end_object(ha_json_builder_t *jb) {
jb->depth--;
ha_json_builder_raw(jb, "}");
}
void ha_json_builder_begin_array(ha_json_builder_t *jb) {
ha_json_builder_comma(jb);
ha_json_builder_raw(jb, "[");
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
jb->depth++;
}
void ha_json_builder_end_array(ha_json_builder_t *jb) {
jb->depth--;
ha_json_builder_raw(jb, "]");
}
void ha_json_builder_key(ha_json_builder_t *jb, const char *key) {
ha_json_builder_comma(jb);
json_escape(jb, key);
ha_json_builder_raw(jb, ":");
}
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) {
json_escape(jb, val);
}
void ha_json_builder_add_int(ha_json_builder_t *jb, int val) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", val);
ha_json_builder_raw(jb, buf);
}
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) {
ha_json_builder_raw(jb, val ? "true" : "false");
}
void ha_json_builder_add_null(ha_json_builder_t *jb) {
ha_json_builder_raw(jb, "null");
}
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) {
ha_json_builder_key(jb, key);
json_escape(jb, val);
}
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) {
ha_json_builder_key(jb, key);
ha_json_builder_add_int(jb, val);
}
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) {
ha_json_builder_key(jb, key);
ha_json_builder_add_bool(jb, val);
}
const char *ha_json_builder_str(ha_json_builder_t *jb) {
return jb->buf;
}
int ha_json_builder_len(ha_json_builder_t *jb) {
return jb->len;
}

107
remotedevice/src/ha_json.h Normal file
View File

@ -0,0 +1,107 @@
#ifndef HA_JSON_H
#define HA_JSON_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ======================== JSON 解析器DOM 风格) ======================== */
typedef enum {
HA_JSON_NULL,
HA_JSON_BOOL,
HA_JSON_INT,
HA_JSON_STRING,
HA_JSON_ARRAY,
HA_JSON_OBJECT,
} ha_json_type_t;
typedef struct ha_json_node {
ha_json_type_t type;
union {
int bool_val;
int int_val;
char *str_val;
};
struct ha_json_node *next; /* linked list for array/object items */
struct ha_json_node *child; /* first child for array/object */
char *key; /* key for object members */
} ha_json_node_t;
/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */
ha_json_node_t *ha_json_parse(const char *str);
/* 从对象中按 key 获取字符串值,不存在返回 NULL */
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key);
/* 从对象中按 key 获取 int 值,不存在返回 def */
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def);
/* 从对象中按 key 获取子节点,不存在返回 NULL */
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key);
/* 获取数组长度 */
int ha_json_array_len(const ha_json_node_t *arr);
/* 获取数组第 index 个元素,越界返回 NULL */
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index);
/* 释放整个 JSON 树 */
void ha_json_free(ha_json_node_t *root);
/* ======================== JSON 构建器(直接写缓冲区) ======================== */
typedef struct {
char *buf;
int len;
int cap;
int depth;
int item_count[16]; /* 每层已添加元素数,用于逗号判断 */
} ha_json_builder_t;
/* 初始化构建器 */
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap);
/* 清空构建器 */
void ha_json_builder_reset(ha_json_builder_t *jb);
/* 基础写入 */
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s);
/* 逗号(自动判断是否需要加) */
void ha_json_builder_comma(ha_json_builder_t *jb);
/* 对象 */
void ha_json_builder_begin_object(ha_json_builder_t *jb);
void ha_json_builder_end_object(ha_json_builder_t *jb);
/* 数组 */
void ha_json_builder_begin_array(ha_json_builder_t *jb);
void ha_json_builder_end_array(ha_json_builder_t *jb);
/* 键名 */
void ha_json_builder_key(ha_json_builder_t *jb, const char *key);
/* 值 */
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val);
void ha_json_builder_add_int(ha_json_builder_t *jb, int val);
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val);
void ha_json_builder_add_null(ha_json_builder_t *jb);
/* 快捷方法:直接写 "key":"val" */
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val);
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val);
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val);
/* 获取当前构建的字符串指针 */
const char *ha_json_builder_str(ha_json_builder_t *jb);
/* 获取当前长度 */
int ha_json_builder_len(ha_json_builder_t *jb);
#ifdef __cplusplus
}
#endif
#endif /* HA_JSON_H */

View File

@ -0,0 +1,628 @@
#include "ha_remotedevice.h"
#include "ha_json.h"
#include "ha_ws.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define HA_VERSION "0.1.0"
/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */
void ha_client_send_result(ha_client_t *client, const char *req_id,
const char *status, const char *output,
const char *error);
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
const char *kind, const char *mime,
const uint8_t *data, int len);
/* ======================== 内部状态 ======================== */
typedef enum {
HA_STATE_INIT,
HA_STATE_DISCONNECTED,
HA_STATE_CONNECTING,
HA_STATE_WS_UPGRADING,
HA_STATE_HELLO_SENT,
HA_STATE_BIND_SENT,
HA_STATE_READY,
HA_STATE_STOPPING,
} ha_state_t;
/* 语音数据聚合缓冲区 */
typedef struct {
char req_id[128];
char kind[64];
char mime[64];
int total;
uint8_t *data;
int len;
int cap;
} ha_speech_accum_t;
struct ha_client {
ha_config_t config; /* 拷贝的配置 */
ha_state_t state;
int reconnect_cnt; /* 当前重连次数 */
ha_ws_t ws; /* WS 连接 */
/* JSON 构建缓冲区 */
char json_buf[4096];
ha_json_builder_t jb;
/* 语音数据聚合 */
ha_speech_accum_t speech;
};
/* ======================== 辅助函数 ======================== */
static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; }
/* Base64 编码表 */
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) {
int needed = ((len + 2) / 3) * 4 + 1;
if (out_len < needed) {
if (out_len > 0) out[0] = '\0';
return needed;
}
int i = 0, j = 0;
while (i < len) {
int rem = len - i;
uint8_t b0 = data[i++];
uint8_t b1 = (rem > 1) ? data[i++] : 0;
uint8_t b2 = (rem > 2) ? data[i++] : 0;
out[j++] = b64[b0 >> 2];
out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)];
out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '=';
}
out[j] = '\0';
return j;
}
/* ======================== JSON 构建辅助 ======================== */
static void json_init(ha_client_t *c) {
ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf));
}
/* ======================== WS 发送 JSON ======================== */
static int ws_send_json(ha_client_t *c) {
return ha_ws_send_text(&c->ws, c->json_buf);
}
/* ======================== 协议消息构造 ======================== */
/* 构建 hello 消息 */
static int send_hello(ha_client_t *c) {
json_init(c);
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "op", "hello");
ha_json_builder_key(&c->jb, "device");
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
ha_json_builder_string(&c->jb, "name", c->config.device.name);
ha_json_builder_string(&c->jb, "kind", c->config.device.kind);
/* caps */
ha_json_builder_key(&c->jb, "caps");
ha_json_builder_begin_array(&c->jb);
if (c->config.device.caps) {
for (const char **p = c->config.device.caps; *p; p++) {
ha_json_builder_add_string(&c->jb, *p);
}
}
ha_json_builder_end_array(&c->jb);
/* info 可选 */
if (c->config.device.info_json && c->config.device.info_json[0]) {
ha_json_builder_string(&c->jb, "info", c->config.device.info_json);
}
ha_json_builder_end_object(&c->jb); /* device */
ha_json_builder_end_object(&c->jb); /* root */
return ws_send_json(c);
}
/* 构建 bind 消息 */
static int send_bind(ha_client_t *c) {
json_init(c);
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "op", "bind");
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
ha_json_builder_string(&c->jb, "token", c->config.token);
ha_json_builder_end_object(&c->jb);
return ws_send_json(c);
}
/* ======================== 消息处理 ======================== */
/* 在 handlers 表中查找命令处理函数 */
static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) {
if (!name || !c->config.handlers) return NULL;
for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) {
if (strcmp(h->command, name) == 0) return h;
}
return NULL;
}
/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */
static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
const char *command = ha_json_get_string(msg, "command");
const char *cmd_type = ha_json_get_string(msg, "cmd_type");
if (!req_id || !command) return;
if (!cmd_type) cmd_type = "homeagent";
const char *handler_name = NULL;
const char *args = command;
if (strcmp(cmd_type, "shell") == 0) {
handler_name = "shell";
/* args 保持为完整命令字符串 */
} else {
/* homeagent-* 命令:提取能力名作为 handler 名 */
const char *cap = command;
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) p += 10;
const char *space = strchr(p, ' ');
if (space) {
args = space + 1;
/* handler_name 用静态缓冲区 */
static char name_buf[128];
int n = (int)(space - p);
if (n > 127) n = 127;
strncpy(name_buf, p, n);
name_buf[n] = '\0';
handler_name = name_buf;
} else {
handler_name = p;
args = "";
}
}
ha_cmd_handler_def_t *def = find_handler(c, handler_name);
if (!def) {
ha_client_send_result(c, req_id, "error", NULL,
"unsupported command");
return;
}
/* 调用 handler填写 result */
ha_cmd_result_t result;
memset(&result, 0, sizeof(result));
ha_status_t st = def->handler(req_id, args, &result, c->config.userdata);
/* 自动回执 */
if (st != HA_OK) {
ha_client_send_result(c, req_id, "error", NULL,
result.error ? result.error : "handler failed");
return;
}
if (result.has_binary && result.binary_data && result.binary_len > 0) {
/* 二进制分块回传 */
ha_client_send_data_chunked(c, req_id,
handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream",
result.binary_data, result.binary_len);
} else {
/* 文本回传 */
ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error",
result.output, result.error);
}
}
static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
const char *kind = ha_json_get_string(msg, "kind");
const char *mime = ha_json_get_string(msg, "mime");
if (!req_id) return;
/* 释放旧的聚合数据 */
free(c->speech.data);
memset(&c->speech, 0, sizeof(c->speech));
strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1);
if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1);
if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1);
c->speech.total = ha_json_get_int(msg, "total", 0);
}
static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return;
if (c->config.on_binary && c->speech.data && c->speech.len > 0) {
c->config.on_binary(c->speech.req_id, c->speech.kind,
c->speech.mime, c->speech.data,
c->speech.len, c->config.userdata);
}
free(c->speech.data);
memset(&c->speech, 0, sizeof(c->speech));
}
static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) {
/* 解析 JSON */
char *tmp = (char *)malloc(len + 1);
if (!tmp) return;
memcpy(tmp, payload, len);
tmp[len] = '\0';
ha_json_node_t *root = ha_json_parse(tmp);
if (!root) { free(tmp); return; }
const char *op = ha_json_get_string(root, "op");
if (!op) { ha_json_free(root); free(tmp); return; }
switch (c->state) {
case HA_STATE_HELLO_SENT:
if (strcmp(op, "hello_ack") == 0) {
c->state = HA_STATE_BIND_SENT;
send_bind(c);
}
break;
case HA_STATE_BIND_SENT:
if (strcmp(op, "bind_ack") == 0) {
c->state = HA_STATE_READY;
if (c->config.on_state) {
c->config.on_state(1, c->config.userdata);
}
}
break;
case HA_STATE_READY:
if (strcmp(op, "cmd") == 0) {
handle_cmd_msg(c, root);
} else if (strcmp(op, "cmd_speech_start") == 0) {
handle_speech_start(c, root);
} else if (strcmp(op, "cmd_speech_end") == 0) {
handle_speech_end(c, root);
}
break;
default:
break;
}
ha_json_free(root);
free(tmp);
}
/* ======================== 连接管理 ======================== */
static int do_connect(ha_client_t *c) {
c->state = HA_STATE_CONNECTING;
c->reconnect_cnt++;
/* 解析 server 地址 */
char host[256] = {0};
uint16_t port = 9890;
const char *p = c->config.server;
if (!p) return -1;
/* 去掉 ws:// 前缀 */
if (strncmp(p, "ws://", 5) == 0) p += 5;
else if (strncmp(p, "wss://", 6) == 0) p += 6;
/* 提取 host:port */
const char *colon = strchr(p, ':');
const char *slash = strchr(p, '/');
if (colon && (!slash || colon < slash)) {
int host_len = (int)(colon - p);
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
memcpy(host, p, host_len);
host[host_len] = '\0';
port = (uint16_t)atoi(colon + 1);
} else {
int host_len = (slash ? (int)(slash - p) : (int)strlen(p));
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
memcpy(host, p, host_len);
host[host_len] = '\0';
}
c->state = HA_STATE_WS_UPGRADING;
if (ha_ws_connect(&c->ws, &c->config.transport, host, port,
"/api/v1/device/ws", c->config.token) != 0) {
c->state = HA_STATE_DISCONNECTED;
return -1;
}
/* 发送 hello */
c->state = HA_STATE_HELLO_SENT;
if (send_hello(c) != 0) {
ha_ws_close(&c->ws);
c->state = HA_STATE_DISCONNECTED;
return -1;
}
return 0;
}
/* ======================== 公共 API ======================== */
ha_client_t *ha_client_new(const ha_config_t *config) {
ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t));
if (!c) return NULL;
memcpy(&c->config, config, sizeof(ha_config_t));
c->state = HA_STATE_INIT;
c->reconnect_cnt = 0;
return c;
}
ha_status_t ha_client_start(ha_client_t *client) {
if (!client) return HA_ERR_INVALID;
if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC;
/* 默认心跳间隔 30 秒 */
if (client->config.ping_interval <= 0) {
client->config.ping_interval = 30;
}
if (do_connect(client) != 0) {
return HA_ERR_TRANSPORT;
}
/* 等待 bind_ack最多 5 秒) */
int wait_ms = 5000;
int step = 50;
while (wait_ms > 0 && client->state != HA_STATE_READY) {
/* 处理一帧 */
ha_status_t st = ha_client_process(client);
if (st != HA_OK && st != HA_ERR_DISCONNECTED) {
return st;
}
if (client->state == HA_STATE_READY) return HA_OK;
/* 简单延时:靠 process 中的 recv 阻塞 */
wait_ms -= step;
}
return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT;
}
ha_status_t ha_client_process(ha_client_t *client) {
if (!client) return HA_ERR_INVALID;
if (client->state == HA_STATE_STOPPING) {
return HA_ERR_DISCONNECTED;
}
/* 断线重连 */
if (client->state == HA_STATE_DISCONNECTED ||
client->state == HA_STATE_INIT) {
if (client->config.max_reconnect >= 0 &&
client->reconnect_cnt > client->config.max_reconnect) {
return HA_ERR_DISCONNECTED;
}
/* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */
return HA_ERR_DISCONNECTED;
}
if (!client->ws.connected) {
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
/* 尝试读取一帧 */
const uint8_t *payload = NULL;
int len = 0;
int ret = ha_ws_read_frame(&client->ws, &payload, &len);
if (ret < 0) {
/* 连接断开 */
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
switch (ret) {
case WS_OPCODE_TEXT:
handle_text_message(client, payload, len);
break;
case WS_OPCODE_BINARY:
/* 二进制帧:如果处于语音聚合状态,追加数据 */
if (client->speech.req_id[0] && payload) {
int new_len = client->speech.len + len;
if (new_len > client->speech.cap) {
int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096;
while (new_cap < new_len) new_cap *= 2;
uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap);
if (!nd) break;
client->speech.data = nd;
client->speech.cap = new_cap;
}
memcpy(client->speech.data + client->speech.len, payload, len);
client->speech.len = new_len;
}
break;
case WS_OPCODE_PING:
/* 回复 pong */
ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0);
break;
case WS_OPCODE_PONG:
/* 收到 pong忽略 */
break;
case WS_OPCODE_CLOSE:
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
return HA_OK;
}
void ha_client_send_result(ha_client_t *client, const char *req_id,
const char *status, const char *output,
const char *error) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_result");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "status", status ? status : "ok");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
if (output && output[0]) {
ha_json_builder_string(&client->jb, "output", output);
}
if (error && error[0]) {
ha_json_builder_string(&client->jb, "error", error);
}
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
const char *kind, const char *mime,
const uint8_t *data, int len) {
if (!client || client->state != HA_STATE_READY) return;
/* cmd_data_start */
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_data_start");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "kind", kind ? kind : "data");
ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream");
ha_json_builder_int(&client->jb, "total", len);
ha_json_builder_int(&client->jb, "chunk_size", 8192);
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
/* 二进制帧分块发送 */
int off = 0;
while (off < len) {
int chunk = len - off;
if (chunk > 8192) chunk = 8192;
if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return;
off += chunk;
}
/* cmd_data_end */
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_data_end");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "status", "ok");
ha_json_builder_int(&client->jb, "total", len);
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_event(ha_client_t *client, const char *type,
const char *detail) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "event");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
ha_json_builder_string(&client->jb, "type", type ? type : "");
if (detail && detail[0]) {
ha_json_builder_string(&client->jb, "payload", detail);
}
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_status(ha_client_t *client, const char *status) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "status");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
ha_json_builder_string(&client->jb, "status", status ? status : "online");
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_stop(ha_client_t *client) {
if (!client) return;
client->state = HA_STATE_STOPPING;
if (client->ws.connected) {
ha_ws_close(&client->ws);
}
}
void ha_client_destroy(ha_client_t *client) {
if (!client) return;
ha_client_stop(client);
free(client->speech.data);
free(client);
}
/* ======================== 工具函数 ======================== */
void ha_cmd_parse_homeagent(const char *command, const char **cap,
const char **args) {
*cap = command;
*args = "";
if (!command) {
*cap = "";
return;
}
/* 去掉 homeagent- 前缀 */
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) {
p += 10;
}
/* 按空格分割 */
const char *space = strchr(p, ' ');
if (space) {
/* cap 指向 p 但不包含空格,需要临时拷贝 */
/* 返回指针到原始字符串,调用方用 strncpy 取出 */
*cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */
/* 实际上,最简单的方式是原地修改,但 const 不允许 */
/* 用静态缓冲区或让调用方自己处理 */
static char cap_buf[256];
int n = (int)(space - p);
if (n > 255) n = 255;
strncpy(cap_buf, p, n);
cap_buf[n] = '\0';
*cap = cap_buf;
*args = space + 1;
} else {
static char cap_buf[256];
strncpy(cap_buf, p, sizeof(cap_buf) - 1);
cap_buf[sizeof(cap_buf) - 1] = '\0';
*cap = cap_buf;
*args = "";
}
}
void ha_cmd_parse_json(const char *command, const char **action,
const char **json_str) {
*action = "";
*json_str = "";
if (!command) return;
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) {
p += 10;
}
const char *brace = strchr(p, '{');
if (brace) {
static char act_buf[256];
int n = (int)(brace - p);
while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--;
if (n > 255) n = 255;
strncpy(act_buf, p, n);
act_buf[n] = '\0';
*action = act_buf;
*json_str = brace;
} else {
static char act_buf[256];
strncpy(act_buf, p, sizeof(act_buf) - 1);
*action = act_buf;
}
}
const char *ha_version(void) {
return HA_VERSION;
}

325
remotedevice/src/ha_ws.c Normal file
View File

@ -0,0 +1,325 @@
#include "ha_ws.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
/* WS GUID 用于计算 Accept 值 */
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
/* ======================== Base64 编码(用于 WS key ======================== */
static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static void base64_encode_bin(const uint8_t *in, int in_len, char *out) {
int i = 0, j = 0;
uint8_t b[3];
while (i < in_len) {
int rem = in_len - i;
if (rem >= 3) {
b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)];
out[j++] = b64t[b[2] & 0x3F];
} else if (rem == 2) {
b[0] = in[i++]; b[1] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
out[j++] = b64t[(b[1] & 0x0F) << 2];
out[j++] = '=';
} else {
b[0] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[(b[0] & 0x03) << 4];
out[j++] = '=';
out[j++] = '=';
}
}
out[j] = '\0';
}
/* 简单伪随机数生成器 */
static uint32_t ws_rand_state = 0;
static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; }
static uint32_t ws_rand(void) {
ws_rand_state = ws_rand_state * 1103515245 + 12345;
return ws_rand_state;
}
/* 生成 WS 握手 key */
static void ws_gen_key(char *out) {
uint8_t buf[16];
for (int i = 0; i < 16; i++) {
buf[i] = (uint8_t)(ws_rand() & 0xFF);
}
base64_encode_bin(buf, 16, out);
}
/* ======================== 从传输层接收指定字节数 ======================== */
static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) {
int pos = 0;
while (pos < len) {
int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos);
if (n <= 0) return -1;
pos += n;
}
return 0;
}
/* ======================== 发送 WS 帧 ======================== */
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) {
uint8_t hdr[14]; /* 最大帧头2 + 8 + 4 = 14 */
int hdr_len = 0;
hdr[0] = 0x80 | opcode; /* FIN + opcode */
hdr_len = 2;
int ext_len = 0;
if (len < 126) {
hdr[1] = 0x80 | len; /* mask bit + length */
} else if (len < 65536) {
hdr[1] = 0x80 | 126;
hdr_len = 4;
hdr[2] = (uint8_t)(len >> 8);
hdr[3] = (uint8_t)(len & 0xFF);
ext_len = 2;
} else {
hdr[1] = 0x80 | 127;
hdr_len = 10;
uint64_t l = (uint64_t)len;
for (int i = 8; i > 0; i--) {
hdr[1 + i] = (uint8_t)(l & 0xFF);
l >>= 8;
}
ext_len = 8;
}
/* mask key */
uint8_t mask_key[4];
mask_key[0] = (uint8_t)(ws_rand() & 0xFF);
mask_key[1] = (uint8_t)(ws_rand() & 0xFF);
mask_key[2] = (uint8_t)(ws_rand() & 0xFF);
mask_key[3] = (uint8_t)(ws_rand() & 0xFF);
int mask_off = 2 + ext_len;
hdr[mask_off] = mask_key[0];
hdr[mask_off + 1] = mask_key[1];
hdr[mask_off + 2] = mask_key[2];
hdr[mask_off + 3] = mask_key[3];
hdr_len = mask_off + 4;
/* 发送帧头 */
if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) {
return -1;
}
/* 发送掩码后的 payload */
if (len > 0) {
/* 如果 payload 不大,用栈缓冲区 */
uint8_t stack_buf[2048];
uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len);
if (!masked) return -1;
for (int i = 0; i < len; i++) {
masked[i] = payload[i] ^ mask_key[i & 3];
}
int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1;
if (masked != stack_buf) free(masked);
if (ret != 0) return -1;
}
return 0;
}
/* ======================== 公共 API ======================== */
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
const char *host, uint16_t port,
const char *path, const char *token) {
memset(ws, 0, sizeof(ha_ws_t));
ws->transport = transport;
ws->connected = 0;
strncpy(ws->host, host, sizeof(ws->host) - 1);
ws->port = port;
strncpy(ws->path, path, sizeof(ws->path) - 1);
if (token) strncpy(ws->token, token, sizeof(ws->token) - 1);
/* 种子 */
ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port);
/* 1. TCP 连接 */
if (transport->connect(transport->ctx, host, port) != 0) {
return -1;
}
/* 2. 发送 WS 升级请求 */
char key[32];
ws_gen_key(key);
char req[1024];
int n = snprintf(req, sizeof(req),
"GET %s HTTP/1.1\r\n"
"Host: %s:%u\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, host, (unsigned)port, key);
/* 如果 token 存在,加到路径参数中 */
if (token && token[0]) {
n = snprintf(req, sizeof(req),
"GET %s?token=%s HTTP/1.1\r\n"
"Host: %s:%u\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, token, host, (unsigned)port, key);
}
if (transport->send(transport->ctx, (uint8_t *)req, n) != n) {
transport->close(transport->ctx);
return -1;
}
/* 3. 读取响应头(直到 \r\n\r\n */
char resp[1024];
int resp_len = 0;
int found = 0;
while (resp_len < (int)sizeof(resp) - 1) {
int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1);
if (n <= 0) {
transport->close(transport->ctx);
return -1;
}
resp_len += n;
resp[resp_len] = '\0';
if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) {
found = 1;
break;
}
}
if (!found) {
transport->close(transport->ctx);
return -1;
}
/* 4. 检查状态码 101 */
if (strstr(resp, " 101 ") == NULL) {
transport->close(transport->ctx);
return -1;
}
ws->connected = 1;
return 0;
}
int ha_ws_send_text(ha_ws_t *ws, const char *text) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text));
}
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len);
}
int ha_ws_send_ping(ha_ws_t *ws) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0);
}
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) {
if (!ws->connected) return -1;
*payload = NULL;
*len = 0;
/* 读取帧头2 字节 */
uint8_t hdr[2];
if (recv_all(ws, hdr, 2) != 0) {
ws->connected = 0;
return -1;
}
int opcode = hdr[0] & 0x0F;
int masked = (hdr[1] & 0x80) ? 1 : 0;
uint64_t frame_len = hdr[1] & 0x7F;
if (frame_len == 126) {
uint8_t ext[2];
if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; }
frame_len = ((uint64_t)ext[0] << 8) | ext[1];
} else if (frame_len == 127) {
uint8_t ext[8];
if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; }
frame_len = 0;
for (int i = 0; i < 8; i++) {
frame_len = (frame_len << 8) | ext[i];
}
}
/* 读取 mask key */
uint8_t mask_key[4] = {0, 0, 0, 0};
if (masked) {
if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; }
}
/* 限制帧大小 */
if (frame_len > sizeof(ws->read_buf)) {
/* 帧太大,跳过 payload */
uint64_t skip = frame_len;
uint8_t tmp[256];
while (skip > 0) {
int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip;
if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; }
skip -= to_skip;
}
return -1; /* 返回错误,帧太大 */
}
/* 读取 payload */
if (frame_len > 0) {
if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) {
ws->connected = 0;
return -1;
}
/* 如果有 mask解掩码 */
if (masked) {
for (uint64_t i = 0; i < frame_len; i++) {
ws->read_buf[i] ^= mask_key[i & 3];
}
}
}
*payload = ws->read_buf;
*len = (int)frame_len;
switch (opcode) {
case WS_OPCODE_CLOSE:
ws->connected = 0;
return WS_OPCODE_CLOSE;
case WS_OPCODE_PING:
return WS_OPCODE_PING;
case WS_OPCODE_PONG:
return WS_OPCODE_PONG;
case WS_OPCODE_TEXT:
case WS_OPCODE_BINARY:
return opcode;
default:
return -1;
}
}
void ha_ws_close(ha_ws_t *ws) {
if (ws->connected) {
ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0);
ws->connected = 0;
}
ws->transport->close(ws->transport->ctx);
}

62
remotedevice/src/ha_ws.h Normal file
View File

@ -0,0 +1,62 @@
#ifndef HA_WS_H
#define HA_WS_H
#include <stdint.h>
#include <stddef.h>
#include "../include/ha_remotedevice.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ======================== WS 帧类型 ======================== */
#define WS_OPCODE_CONTINUATION 0x0
#define WS_OPCODE_TEXT 0x1
#define WS_OPCODE_BINARY 0x2
#define WS_OPCODE_CLOSE 0x8
#define WS_OPCODE_PING 0x9
#define WS_OPCODE_PONG 0xA
/* ======================== WS 连接 ======================== */
typedef struct {
ha_transport_t *transport; /* 用户实现的传输层 */
int connected; /* 是否已连接 */
uint8_t read_buf[8192]; /* 读缓冲区 */
int read_pos; /* 缓冲区中有效数据起始位置 */
int read_len; /* 缓冲区中有效数据长度 */
char host[256]; /* 缓存目标地址 */
uint16_t port;
char path[256];
char token[256];
} ha_ws_t;
/* 创建 WS 连接。返回 0 成功,非 0 失败。 */
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
const char *host, uint16_t port,
const char *path, const char *token);
/* 发送文本帧。返回 0 成功。 */
int ha_ws_send_text(ha_ws_t *ws, const char *text);
/* 发送二进制帧。返回 0 成功。 */
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len);
/* 发送 ping。返回 0 成功。 */
int ha_ws_send_ping(ha_ws_t *ws);
/* 读取一帧。
* 返回 opcode (0x1/0x2/0x8/0x9/0xA)-1 表示关闭或错误。
* payload 和 len 指向内部缓冲区,在下次调用前有效。 */
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len);
/* 发送原始 WS 帧(内部使用,用于回复 ping */
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len);
/* 关闭 WS 连接 */
void ha_ws_close(ha_ws_t *ws);
#ifdef __cplusplus
}
#endif
#endif /* HA_WS_H */

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,14 @@ const (
StageAfterOutput Stage = "after_output"
)
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
type ChannelDef struct {
NoMemory bool
Cleaner func(string) string
}
// StageContext provides context for stage handlers.
type StageContext struct {
mu sync.RWMutex
@ -92,6 +100,8 @@ type ToolDef struct {
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
@ -101,6 +111,9 @@ type IOInjector interface {
InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
InjectInputSync(source, channel, text string) string
}
// EventType identifies the kind of system event.
@ -114,6 +127,11 @@ const (
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
EventSystem EventType = "system"
// 流式增量事件token 级):核心 process() 流式化后每收到一个增量块发布。
// 客户端可选订做真逐 token 渲染;聚合事件仍照常发布,旧订阅者不受影响。
EventReasoningDelta EventType = "reasoning_delta"
EventContentDelta EventType = "content_delta"
)
// Event represents a system event published by the kernel.
@ -134,6 +152,17 @@ type EventSubscriber interface {
Subscribe(eventType EventType, handler EventHandler) func()
}
// PluginMgrAPI 提供插件管理能力(外部插件可调用)。
// 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
type PluginMgrAPI interface {
// ReloadOne 重载单个插件(停止后重新加载)。
ReloadOne(name string) error
// ListLoadedPlugins 列出已加载插件。
ListLoadedPlugins() []string
// IsPluginDisabled 查询插件是否被禁用。
IsPluginDisabled(name string) bool
}
// StageScope controls which events a stage handler receives.
type StageScope int
@ -154,8 +183,11 @@ type StageRegistrar func(stage Stage, handler StageHandler)
// APIRegistrar registers a plugin API for external access.
type APIRegistrar func(name string) error
// InputChannelRegistrar registers an input channel with its memory behavior.
type InputChannelRegistrar func(name string, def ChannelDef) error
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
// Output capability flags
const (
@ -174,6 +206,7 @@ type PluginSDK struct {
regStage StageRegistrar
regAPI APIRegistrar
regOutput OutputChannelRegistrar
regInput InputChannelRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
@ -183,8 +216,15 @@ type PluginSDK struct {
sett SettingsAPI
social SocialAPI
events EventSubscriber
plgMgr PluginMgrAPI
autoRestart bool
stopMu sync.Mutex
stopHandlers []func()
removeMu sync.Mutex
removeHandlers []func()
}
// New creates a PluginSDK with the given dependencies.
@ -287,10 +327,21 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
// name: channel name (e.g. "qq", "webui")
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum
// def: 通道在记忆计算层的行为NoMemory/Cleaner
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error {
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
if s.regOutput != nil {
return s.regOutput(name, caps, desc, handler)
return s.regOutput(name, caps, desc, def, handler)
}
return nil
}
// RegisterInputChannel registers an input channel with its memory behavior.
// def.NoMemory: 此通道输入不参与记忆计算
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
if s.regInput != nil {
return s.regInput(name, def)
}
return nil
}
@ -298,6 +349,9 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, ha
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r }
// SetIOInjector sets the IO injector (called by the core at startup).
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
@ -310,6 +364,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
// May be nil if the host did not wire it.
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
// ---- IO Convenience Methods ----
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
@ -333,9 +394,69 @@ func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
}
}
// InjectInputSync injects a text message and synchronously waits for the agent reply,
// returning the reply text (empty string if none). Replies must be dispatched back
// to the source channel by the caller.
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
if s.io == nil {
return ""
}
return s.io.InjectInputSync(source, channel, text)
}
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
// 默认 true。如果插件有无法恢复的状态如外部连接应设为 false。
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
// AutoRestart 返回插件是否允许自动重启。
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
// RegisterStopHandler 注册插件停止阶段的清理回调。
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
// 适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
// 可注册多个;执行后清空(进程停止前只执行一次)。
func (s *PluginSDK) RegisterStopHandler(fn func()) {
if fn == nil {
return
}
s.stopMu.Lock()
s.stopHandlers = append(s.stopHandlers, fn)
s.stopMu.Unlock()
}
// RunStopHandlers 执行全部已注册的 stop handler后注册先执行执行后清空幂等
// 由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin在调用插件 Stop() 前执行。
func (s *PluginSDK) RunStopHandlers() {
s.stopMu.Lock()
handlers := append([]func(){}, s.stopHandlers...)
s.stopHandlers = nil
s.stopMu.Unlock()
for i := len(handlers) - 1; i >= 0; i-- {
handlers[i]()
}
}
// RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
// 注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
// 适用于清理外部资源、删除配置表、下线状态等删除后处理。
// 可注册多个;执行后清空(一次删除只执行一次)。
func (s *PluginSDK) RegisterOnRemoveHandler(fn func()) {
if fn == nil {
return
}
s.removeMu.Lock()
s.removeHandlers = append(s.removeHandlers, fn)
s.removeMu.Unlock()
}
// RunOnRemoveHandlers 执行全部已注册的 onRemove handler后注册先执行执行后清空幂等
// 由内核在卸载插件registry.RemovePlugin时、插件 Stop() 之后执行。
func (s *PluginSDK) RunOnRemoveHandlers() {
s.removeMu.Lock()
handlers := append([]func(){}, s.removeHandlers...)
s.removeHandlers = nil
s.removeMu.Unlock()
for i := len(handlers) - 1; i >= 0; i-- {
handlers[i]()
}
}

View File

@ -177,3 +177,77 @@ func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
s := &PluginSDK{name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
}
func TestToolDefCleaner(t *testing.T) {
called := false
def := ToolDef{
Name: "test_clean",
Description: "A test tool with cleaner",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Cleaner: func(output string) string {
called = true
return "cleaned:" + output
},
}
if def.Cleaner == nil {
t.Fatal("Cleaner should not be nil")
}
result := def.Cleaner("raw output")
if !called {
t.Error("Cleaner was not called")
}
if result != "cleaned:raw output" {
t.Errorf("expected 'cleaned:raw output', got '%s'", result)
}
}
func TestToolDefNoMemory(t *testing.T) {
def := ToolDef{
Name: "test_nomem",
Description: "A test tool with NoMemory",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: true,
}
if !def.NoMemory {
t.Error("NoMemory should be true")
}
if def.Cleaner != nil {
t.Error("Cleaner should be nil when not set")
}
}
func TestToolDefNoMemoryDefaultFalse(t *testing.T) {
def := ToolDef{
Name: "test_default",
Description: "A test tool with defaults",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}
if def.NoMemory {
t.Error("NoMemory should default to false")
}
}
func TestToolDefRegisterPreservesNoMemory(t *testing.T) {
var capturedDef ToolDef
regTool := func(name string, def ToolDef, handler ToolHandler) error {
capturedDef = def
return nil
}
s := &PluginSDK{regTool: regTool, name: "test"}
def := ToolDef{
Name: "test_tool",
Description: "test desc",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: true,
Cleaner: func(s string) string { return s },
}
s.RegisterTool("test_tool", def, func(args map[string]interface{}) (interface{}, error) {
return nil, nil
})
if !capturedDef.NoMemory {
t.Error("NoMemory should be preserved through RegisterTool")
}
if capturedDef.Cleaner == nil {
t.Error("Cleaner should be preserved through RegisterTool")
}
}

6
tools/gengskill/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.pyc
.DS_Store
report/
figures/
*.egg-info/

21
tools/gengskill/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Geng Skill Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More