22 Commits

Author SHA1 Message Date
e256023399 docs+plugindev: README 同步子进程架构,scaffold 修 entry 与 go.sum 死路
三类问题,都会让新用户第一次上手就走错:

1. README 仍写 plugin.so
   plugin.json 示例的 entry、entry 字段说明、.hmap 包格式三处描述的都是
   已退场的 C ABI 产物。改为 plugin.bin,并补 bundle 模式下
   plugin.bin.<goos>.<goarch> 的命名与安装时挑平台的行为。

2. cmd_init.go 的 entry := "plugin.so"
   scaffold 出来的 plg.json 带着一个已退场的 entry 值。build 实际不看这个
   值(只用它区分 Lua),但跟着模板走会误以为自己在做 C ABI 插件。

3. 生成的项目第一次 build 必定失败
   go.mod 只 require 一个 gitcode 模块版本号且不生成 go.sum。gitcode 不在
   proxy.golang.org 上,于是:
     go build     → missing go.sum entry
     go mod tidy  → 去公共 proxy 拉一个不存在的条目,超时
   原来 cmdBuild 里那句 `go mod download <mod>` 走的正是这条死路,失败后
   只打一行 warn 就继续编译,紧接着死在同一个错误上——用户看到两段无关报错。

   修法分两处:
   - 生成的 go.mod 直接写指向本机 SDK 的 replace(replace 到目录时 go 不
     需要也不校验 go.sum)
   - 新增 ensureSDKResolvable:三级策略(已有本地 replace → 探测本机 SDK
     并写入 → 兜底 go mod tidy 带 -mod=mod,失败给可操作提示)。存量项目
     go.mod 无 replace 时走第二级救回。

bin/ 5 个预编译二进制不再进仓库(改为 release 附件):
5 个平台各 26-28MB,每次重编都在 git 历史里再叠一份,而它们本质是可从源码
复现的产物。README 的下载说明同步改为 release 附件 URL + 从源码编译。
2026-09-03 19:27:03 +08:00
092d8f4ab0 Merge branch 'feature/plugin-proc-migration' into main
SDK 侧配合内核子进程化迁移:plugindev 工具链产出 plugin.bin
(纯 Go 二进制,零 cgo),模板支持共享内存 stage 与事件环消费。

- entry 语义收敛到 plugin.bin,删除 C ABI 工具链
- stage 回传改 diff,只回传变更字段(修复 lost update)
- 模板支持事件环消费(evtConsumerLoop)
- Windows 共享内存适配(OpenFileMappingW / OpenEventW)
- meta.Version 升到 1.0.0,与内核对齐

公开 SDK 接口(sdk/ 目录)全程零改动——接口冻结不变量。
2026-09-03 13:54:21 +08:00
5ed8d65479 meta: 版本升到 1.0.0,删除 C ABI 时代的死常量
## 为何是主版本号

公开 SDK 接口(sdk/ 目录)本轮**零改动**,插件业务代码一行不用改。
但产物形态变了:plugin.so → plugin.bin。0.9.x 内核只会 dlopen `.so`,
本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别——
这是不可互操作的破坏性变化,故 CoreVersion 也升 1.0.0 作为**硬下限**
而非建议值。

## 删掉的死代码

ABIVersion / ABIVersionMin / CABINum / CABINumMin / 51 个 Core<Method>
整数 ID,全部无使用者:

    grep -rn "CABINum" --include=*.go --include=*.tmpl .   → 只有定义处
    grep -rn "meta.Core[A-Z]" ...                          → 无

它们随 Part 6.2 删除 internal/plugin/cabi/ 就已失效:
  - 整数 method id 平移为 method 名字符串(proc/protocol.go 的 Method* 常量)
  - 版本协商改为握手帧里的 protocol 字段

留着有实际危害——下一个读这个文件的人会以为 C 层协商还在生效,
或者以为加 method 时要同步维护那张整数表。

## 协议版本与语义版本解耦

新注释写明:子进程 RPC 的 protocol 是独立小整数(当前 1),
只在帧格式或握手语义变化时升;语义版本变动频繁(修 bug、加字段)
不应牵动 wire 协议。这两者以前被 ABIVersion = CoreVersion 绑在一起,
现在分开。

验证:go build ./... 通过;git diff sdk/ 为空(接口冻结不变量)。
2026-09-02 22:40:15 +08:00
9f844123fe plugindev: entry 语义收敛 + 删 C ABI 工具链 + Windows 共享内存适配(Part 6.1)
## entry 不再是通道开关 —— 外部插件零改动的关键

17 个存量插件的 plg.json 都写着 "entry": "plugin.so"。若把 entry 当通道
开关,迁移就得改 17 个文件,而「外部插件零改动」是本次迁移的硬约束。

改法:Go 插件一律产出 plugin.bin,不看 entry 值。isProcEntry 删除,
resolveBuild 去掉 proc 参数。entry 现在只剩区分 Lua(main.lua)一个用途。

实测:weather 的 plg.json 一行不改(仍写 plugin.so),plugindev build
直接产出三平台 plugin.bin。

## Windows 不再是能力退化的第三套实现(§9.2 的正解)

C ABI 时代 Windows 是独立的第三套 ABI:dynamic_dll_windows.go 的 stage
只下发 3 个字段(raw_message/user_id/phase)且完全没有写回,sanitizer
这类改写型插件在 Windows 上静默失效,且无任何运行时警告。

现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局。平台差异
收敛到三个挂载函数:
- Unix(linux/darwin/freebsd):内核经 ExtraFiles 传继承 fd(3=StageContext
  段,4=事件环段,5=eventfd/pipe)
- Windows:没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 不支持),
  改用命名内核对象——父进程 CreateFileMapping/CreateEvent 建带名字的对象,
  子进程 OpenFileMappingW/OpenEventW 按同名打开。名字经环境变量传入而非
  硬编码:多个 homed 实例并存时不能撞名。

Windows 绑定用 syscall.NewLazyDLL 而非 golang.org/x/sys/windows:
OpenFileMappingW/OpenEventW 未被标准库 syscall 导出,而引入 x/sys 会给
**每个插件的 go.mod** 加一个新依赖,违反「插件仅依赖公开 SDK」。
LazyDLL 属标准库,零新增依赖。

新增 evtWaiter 接口抽象等待语义:eventfd 是计数器(多事件合并成一次
唤醒),Windows Event 是二元信号。不影响正确性——消费者被唤醒后按
readSeq 追 writeSeq 批量 drain,一次唤醒能处理累积的全部事件。

模板拆成三个文件:
  proc_main.go.tmpl          平台无关(RPC + 共享段布局 + stage + 事件环消费)
  proc_shm_unix.go.tmpl      继承 fd 挂载
  proc_shm_windows.go.tmpl   命名对象挂载

## 删除 C ABI 工具链

templates.go 1296 → 516 行:
- tmplBridge(Windows DLL bridge)      -265 行
- tmplLinuxBridge(Linux c-shared)     -457 行
- tmplPluginInitC(C 入口)              -57 行
另删 generateBridge / detectWindowsCC(MinGW 探测)/ tmplCABIHeader /
InitData.CABIVersion+CABIHeader。

交叉编译不再需要目标平台 C 工具链——这是 -buildmode=c-shared 退场的
连带收益(§3.1)。

## 测试

15 项全过,新增 4 项守护迁移不变量:
- AllPlatformsProduceBin:6 个 GOOS/GOARCH 组合统一产出 plugin.bin
- LuaIsSeparatePath:Lua 仍走解释器路径
- UnsupportedOSErrors:不支持平台明确报错,不静默产出错误产物
- NoCABIResiduals:代码中不得再出现 c-shared / CGO_ENABLED=1 /
  detectWindowsCC / tmplLinuxBridge / tmplPluginInitC(注释除外)
- IgnoresEntryForGoPlugins:isProcEntry 必须已删除

验证:go build/vet/test 全通过;三平台交叉编译产出 plugin.bin;
git diff sdk/ 为空(接口冻结)。

Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 18:39:02 +08:00
ef0e58ee23 plugindev: 模板支持事件环消费(Part 5 子进程侧)
handleHandshake 额外挂载 fd 4(事件环段)+ fd 5(eventfd),启动
evtConsumerLoop goroutine 消费事件。

HandshakeParams 新增 evt_ring_size 字段(0 = 不支持事件环)。

events.subscribe:按类型列表在本地注册 handler,evtConsumerLoop 从
共享段读 slot 后按位索引分发。events.unsubscribe 清空全部 handler。

事件环布局常量与内核 internal/plugin/proc/evtring.go 一一对应。

验证:weather.bin 零改动编译通过;E2E 测试全通过。
Ref: docs/zh/架构迁移评估.md §3.6
2026-09-02 17:05:01 +08:00
09b64dcb53 plugindev: 支持子进程插件构建(entry=plugin.bin,零 cgo)
Part 3 工具链改造。插件业务代码零改动,只需把 plg.json 的 entry
从 plugin.so 换成 plugin.bin。

新增 templates/proc_main.go.tmpl(1113 行)——子进程运行时:
- 51 个 core method 的插件侧 RPC 实现(procIO/procMemory/procSettings/
  procSocial/procLLM/procKnowledge/procDocMemory/procTextMemory/procPluginMgr)
- 共享段访问(fd 3 = 内核经 ExtraFiles 传入的 memfd)+ 16 字段
  StageContext 编解码,布局常量与 internal/plugin/proc/shm.go 逐一对齐
- handleStageInvoke:拿锁 → 读段 → handler → **只写脏字段** → 放锁。
  只读插件脏字段集为空 → 零写入 → 不可能覆盖他人改写
  (对照 C ABI 副本模型实测 35.8~36.8% lost update)
- 主循环每请求独立 goroutine:handler 内会反向调用内核并等应答,
  在读循环里同步处理会死锁
- plugin.start 后显式上报 AutoRestart:公开 SDK 的 SetAutoRestart 是纯
  setter 无 hook,隔着进程边界内核读不到(内核侧 corehandler.go:145 已就绪)

模板选择真实 .go 源文件 + //go:embed 而非 raw string:900+ 行代码塞在
字符串里写错只能等生成插件时才炸,作为源文件可被 parser/gofmt/vet 检查。

cmd_build.go:resolveBuild(target, proc) 分派;proc 走 go build -trimpath
+ CGO_ENABLED=0,交叉编译不再需要目标平台 C 工具链。bundle 模式各平台
产物同名故 zip 内加平台后缀(plugin.bin.linux.amd64)。

proc_runtime.go 生成时清理残留 z_bridge_gen.go/z_entry.c——同目录两套
main 会编译冲突,这让 .so → .bin 切换无需人工清理。

proc_runtime_test.go 16 项静态检查,防内核/插件两侧漂移:
method 名清单、7 个内核调用、共享段常量与字段枚举顺序、stage 加锁顺序、
快照必须存序列化字符串(切片共享底层数组的坑在 11.3 已踩过)、
arena 不足须报错、日志走 stderr、版本不匹配须拒绝、零 cgo。

验证:真实 plugindev 构建 example/weather,plugin.go 逐字节未改,
产出静态链接 ELF;git diff sdk/ 为空(接口冻结)。

Ref: docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3
2026-09-02 12:13:45 +08:00
dev
56485194df fix(plugindev): stage 回传改 diff,只回传变更字段,修复 lost update(plan 11.3)
根因:go_invoke_stage 无条件回传 stageContextWritable 全部字段(含插件从内核收到
的旧快照),sanitizer(改ToolResults)+weather(只读) 并发时,只读插件把自己收到的
旧快照覆盖回清洗结果(实验13 实测丢失率 1.6~4.3%,脏数据进LLM)。

改动:
- templates.go: 新增 snapshotWritable(handler 前的序列化快照)+ changedFieldsOnly(只回传差异字段)
  go_invoke_stage 改为 before 快照 → handler → diff 回传,无变更零回传
- 关键陷阱(第一版踩坑):stageContextWritable 的切片字段与 sc 共享底层数组,
  handler 原地改元素时 before 快照跟着变,diff 失效——故 before 必须序列化成字符串
- stagediff_test.go: 6 用例(只读零回传/原地改切片/标量改/新response/清空切片/现网场景复刻)

⚠️ 需用新 plugindev 重编全部 17 个外部插件(bridge 模板变更)
2026-08-31 12:29:57 +08:00
61f307be1a feat(qq): msg_id→get_history 7天兜底 + list_chats/mark_read 会话列表 v1.2.0
## 问题

1. NapCat get_msg 的 message_id 是 QQ 服务端临时短号,约 3 天后失效。
   实测 1306 条真实 webhook 消息,141 条(10.8%)现在查回报「消息不存在」,
   全是 3 天前的旧消息;1 小时内的消息可正常查回。NapCat 侧无保留时长配置项
   (napcat.json / onebot11_*.json / webui.json 均无),是 QQ 协议硬限制。
   模型收到 not_found 后曾编造正文(虚构 message_id + 虚构需求),
   已在核心 prompt 加事实性约束,此处从插件层根治取不到正文的问题。

2. 插件与真人客户端差距大:没有会话列表、消息不按到达先后排序、无未读提醒,
   模型只能靠单条中断消息被动响应,导致消息处理不及时。

## 改动

### msg_id → get_history 兜底(不缓存正文)
- 新增 msgRef{peerID,isGroup,time}:只记 msg_id → (peer, 时间) 映射,7 天 TTL
  (qqMsgTTL),超 2000 条时惰性清理过期项。不缓存消息正文。
- handleGetMessage 改为包装层:命中映射且 <7 天 → getMsgFromHistoryByTime()
  按 peer 拉 get_history(count=50),取时间最接近的一条,
  经 msgToGetMsgResult() 包装为与 get_msg 同构的结果(附 resolved_via:history);
  未命中或超 7 天 → 回退原 NapCat get_msg(重命名为 getMsgFromNapcat)。
- 超 7 天的消息由 get_history 自行处理,插件不做长期缓存。
- not_found 文案改为明确引导改用 qq_get_history / qq_list_chats。

### 会话列表(对齐真人客户端)
- 新增 chatMeta:会话名、未读数、最新一条 ≤60 字摘要(qqLastSumLen)、最新时间。
  只维护最新一条摘要,不存历史。
- 新增 qq_list_chats:按最新消息时间降序返回会话列表,每项含
  peer_id/type/name/unread/last_text/last_nick/last_time。
- 新增 qq_mark_read:按 group_id/user_id 清零未读;get_history 拉取某会话后
  自动标已读(看过=已读,与真人客户端一致)。
- webhook 记录时机前移:策略允许的消息(群/私聊、是否 @bot 均记)都进入映射与
  会话状态,@bot 只决定是否发中断——与真人客户端一致能看到全部会话。

### 中断模板
- 补 fallback 路径与私聊 user_id(原模板只给 message_id,取不到正文时
  模型没有 peer 信息可用于 get_history):
  「先用 get_message 取正文;若取不到(已过期),改用 get_history(...) 按会话拉取,
   或用 list_chats 查看未读会话。用 output_send__qq 回复」

## 验证

用重建的 plugindev build --target linux/amd64 产出 dist/qq_linux_amd64.hmap,
经内核 plugin_install(overwrite=true) 安装(action=reinstalled, config_kept=true),
重启 homed 后内核注册 20 个 qq_* 工具(原 18 + list_chats + mark_read)。
实测 qq_list_chats(count=8) 返回按时间排序的会话,unread=6 正确累积。

注意:cgo c-shared 插件带完整 Go runtime,dlclose 后引用计数不归零,
同路径 dlopen 复用旧映像,plgreload 无法热替换 .so,换 .so 必须重启 homed。
2026-08-30 17:11:57 +08:00
59c6e1844c fix(plugindev): bridge 模板补 dispatchIO.SetToolBlocks,修复外部插件无法编译
SDK v0.9.2(68497b4)给 IOInjector 加了 SetToolBlocks,但 plugindev 的
C ABI bridge 模板(tmplLinuxBridge)未同步,导致任何外部插件在当前 SDK 下
编译失败:

  z_bridge_gen.go:97: cannot use dispatchIO{} as sdk.IOInjector value in
  argument to base.SetIOInjector: dispatchIO does not implement
  sdk.IOInjector (missing method SetToolBlocks)

补空实现满足接口。SetToolBlocks 是 Go 原生(进程内 IOManager)的多模态注入,
跨 ABI 无对应 method id 与内核桥接,故不做 callVoid 转发。

实测:example/qq 用重建后的 plugindev build --target linux/amd64 构建通过,
产出 .hmap 经 plugin_install 安装、重启后 20 个工具全部注册成功。
2026-08-30 17:11:20 +08:00
5c1574be25 bump: sdk v0.9.2 (SetToolBlocks + DataDir) 2026-08-27 09:32:01 +08:00
68497b4092 feat(sdk): IOInjector.SetToolBlocks + ContentBlock/ImageURL/AudioURL 多模态类型
SDK 公共层新增多模态内容块类型;IOInjector 接口新增 SetToolBlocks
方法让插件工具注入 image_url/audio_url 块,内核 process.go 消费后
追加到 tool message 的 content 数组。详见 TrueAgent 仓库 multimodal。
2026-08-27 08:39:40 +08:00
130f805b6e feat(sdk): SettingsAPI.DataDir() + plugindev dispatchSettings 补 DataDir
SDK SettingsAPI 新增 DataDir() 插件专属数据目录;
plugindev 工具链 dispatchSettings 模板补 DataDir 实现(callString 51)。
ai_image example 改用 DataDir + 本地交付。详见 TrueAgent 仓库。
2026-08-26 21:41:28 +08:00
cd1984e26e feat(ai_image): base_url 设置项支持自定义 OpenAI 兼容网关 v1.1.0
generateOpenAI 支持配置 base_url 指向 OpenAI 兼容网关(如本机
llmsproxy),为空保持官方直连。已实测经 llmsproxy→siliconflow
(Kwai-Kolors/Kolors) 生图出有效 PNG。
2026-08-26 19:47:01 +08:00
6184736fd4 feat(examples): a2a/acp 会话历史查询 + 出站 session_id 透传
a2a tasks.get/session.get 返回会话近N条消息;acp 新增 session/get;
a2a_query/acp_query 接受 session_id 延续对方会话。详见 TrueAgent 仓库。
2026-08-26 19:30:34 +08:00
81bfdfce1d fix(examples): a2a/acp 回复闭环 + 会话延续 + 同步注入
入站请求从 InjectInterruptText(202 submitted) 改为 InjectInputSync
同步等待回复,直接返回回复文本;支持 params.session_id 延续多轮
上下文;注册为输出通道让回复有落点。详见 TrueAgent 仓库同名 commit。
2026-08-26 19:16:49 +08:00
e3f93e254b chore(recoverydiag): 补齐 go.mod 与 main.go(与其他插件结构一致) 2026-08-26 17:05:24 +08:00
d57c5eaf3e fix(examples): 全插件安全审查修复(qq/a2a/memo/calendar/rss/browser/bili/recoverydiag)
审查发现并修复 7 项问题:
- P1 qq: downloadURL 裸 http.Get 无超时 → 120s client
- P2 a2a: inbound http.Server 零超时 → Read 30s/Write 120s/Idle 60s
- P3 bili: output_dir 配置项零校验 → 系统目录黑名单(/、/etc、/usr、/var 等)
- P4 recoverydiag: db_path LLM 可控任意 sqlite → 强制限制 data 目录内
- P5 memo/calendar/rss: os.WriteFile 直写 → atomicWriteJSON (temp+rename)
- P6 qq: 3 处后台 goroutine(已读/rcon转发/下载)加 panic recover
- P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程

recoverydiag 此前被 .gitignore 排除,但其 db_path 安全修复
属生产代码,故取消忽略并入库。

全部经 plugindev 重打包升版安装验证 config_kept=true。
2026-08-26 17:04:41 +08:00
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
53 changed files with 9424 additions and 1034 deletions

7
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Build artifacts # Build artifacts
*.so *.so
*.dll *.dll
*.o
*.exe
*.hmap *.hmap
plugin.json plugin.json
@ -8,6 +10,11 @@ plugin.json
build/ build/
dist/ dist/
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB每次重编都在历史里
# 再叠一份,而它们本质是可从源码复现的产物。
bin/
# Test artifacts # Test artifacts
testdist/ testdist/

343
README.md
View File

@ -142,13 +142,21 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
## plugindev 工具链 ## plugindev 工具链
`plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制linux/darwin/windows × amd64/arm64下载后直接加入 PATH 即可: `plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发linux/darwin/windows × amd64/arm64
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
```bash ```bash
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64 # 从 release 附件下载(以 v1.0.0 / linux amd64 为例)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev chmod +x plugindev
# 或从源码自己编
cd tools/plugindev && go build -o plugindev .
``` ```
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用5 个平台各 26-28MB
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md | | `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md |
@ -180,7 +188,7 @@ chmod +x plugindev
"version": "1.0.0", "version": "1.0.0",
"description": "天气查询插件", "description": "天气查询插件",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.bin",
"tags": ["weather", "forecast"], "tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64", "targets": "linux/amd64,windows/amd64",
"outdir": "dist", "outdir": "dist",
@ -202,7 +210,7 @@ chmod +x plugindev
| `version` | string | 版本号 | | `version` | string | 版本号 |
| `description` | string | 插件描述 | | `description` | string | 插件描述 |
| `author` | string | 作者 | | `author` | string | 作者 |
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua` | | `entry` | string | 入口文件(`plugin.bin` / `main.lua`。v1.0.0 起 Go 插件统一为 `plugin.bin`,不再区分平台后缀 |
| `tags` | string[] | 标签 | | `tags` | string[] | 标签 |
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` | | `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` |
| `outdir` | string | 输出目录(默认 `dist` | | `outdir` | string | 输出目录(默认 `dist` |
@ -217,11 +225,14 @@ chmod +x plugindev
`.hmap` 为 ZIP 归档,包含: `.hmap` 为 ZIP 归档,包含:
- `plugin.json` — 插件元数据 - `plugin.json` — 插件元数据
- `plugin.so` — Go 编译产物(Linux - `plugin.bin` — Go 编译产物(单平台构建
- `plugin.dll` — Go 编译产物Windows - `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
- `plugin.dylib` — Go 编译产物macOSbundle 模式) 安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
- `main.lua` — Lua 插件入口Lua 插件时) - `main.lua` — Lua 插件入口Lua 插件时)
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
## 插件生命周期 ## 插件生命周期
### 入口函数 ### 入口函数
@ -308,6 +319,324 @@ enabled := sdk.AutoRestart()
| [rss](example/rss) | Go | RSS 订阅 | | [rss](example/rss) | Go | RSS 订阅 |
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 | | [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`
## 构建与安装 ## 构建与安装
### 构建 ### 构建

View File

@ -142,14 +142,30 @@ Plugin developers only need to implement the `Plugin` interface and export a `Ne
## plugindev Toolchain ## plugindev Toolchain
`plugindev` provides full development workflow support: `plugindev` provides full development workflow support. Prebuilt binaries ship as **release assets**
(linux/darwin/windows × amd64/arm64); download from
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
```bash
# From release assets (v1.0.0 / linux amd64 shown)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev
# Or build from source
cd tools/plugindev && go build -o plugindev .
```
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
> reproducible from source anyway.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) | | `plugindev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
| `plugindev build` | Build plugin, output .hmap package | | `plugindev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
| `plugindev clean` | Clean build artifacts | | `plugindev clean` | Clean `build/` and `dist/` plus generated files |
| `plugindev debug` | Run plugin in local debug mode | | `plugindev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
| `plugindev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
Supports both **Go** and **Lua** plugin languages. Supports both **Go** and **Lua** plugin languages.
@ -163,7 +179,7 @@ Supports both **Go** and **Lua** plugin languages.
"version": "1.0.0", "version": "1.0.0",
"description": "Weather plugin", "description": "Weather plugin",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.bin",
"tags": ["weather", "forecast"], "tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64", "targets": "linux/amd64,windows/amd64",
"outdir": "dist", "outdir": "dist",
@ -185,7 +201,7 @@ Supports both **Go** and **Lua** plugin languages.
| `version` | string | Version | | `version` | string | Version |
| `description` | string | Plugin description | | `description` | string | Plugin description |
| `author` | string | Author | | `author` | string | Author |
| `entry` | string | Entry file (`plugin.so` / `main.lua`) | | `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
| `tags` | string[] | Tags | | `tags` | string[] | Tags |
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) | | `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
| `outdir` | string | Output directory (default `dist`) | | `outdir` | string | Output directory (default `dist`) |
@ -198,10 +214,15 @@ Supports both **Go** and **Lua** plugin languages.
`.hmap` is a ZIP archive containing: `.hmap` is a ZIP archive containing:
- `plugin.json` — plugin metadata - `plugin.json` — plugin metadata
- `plugin.so` — Go compiled artifact (Linux) - `plugin.bin` — Go compiled artifact (single-platform build)
- `plugin.dll` — Go compiled artifact (Windows) - `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
the one matching the current platform and renames it to `plugin.bin`
- `main.lua` — Lua plugin entry (for Lua plugins) - `main.lua` — Lua plugin entry (for Lua plugins)
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
## Plugin Lifecycle ## Plugin Lifecycle
### Start & Stop ### Start & Stop
@ -265,6 +286,305 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
| [rss](example/rss) | Go | RSS subscriptions | | [rss](example/rss) | Go | RSS subscriptions |
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering | | [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 ## Building & Installing
### Build ### Build

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2,14 +2,18 @@
"name": "a2a", "name": "a2a",
"name_zh": "A2A 代理通信", "name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication", "name_en": "A2A Agent Communication",
"version": "1.0.0", "version": "1.3.0",
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。", "description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["a2a", "agent", "interop"], "tags": [
"a2a",
"agent",
"interop"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -21,15 +21,47 @@ type Plugin struct {
srvMu sync.Mutex srvMu sync.Mutex
server *http.Server server *http.Server
serverAddr string serverAddr string
// 会话表session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
sessMu sync.Mutex
sessions map[string]*a2aSession
} }
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
type a2aSession struct {
ID string
History []string // 轮次文本 [user1, agent1, user2, agent2, ...]
LastUsed time.Time
}
// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。
const maxSessionTurns = 10
// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。
const sessionGCPeriod = 30 * time.Minute
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true) s.SetAutoRestart(true)
p.sdk = s p.sdk = s
p.sessions = make(map[string]*a2aSession)
tp := p.name + "_" tp := p.name + "_"
// 注册自身为输出通道agent 回复 emit 到本通道时有落点,
// 且 output_list_channels 可见agent 能主动向 a2a 会话推送消息)。
if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120))
return map[string]interface{}{"status": "ok"}, nil
}); err != nil {
log.Printf("[%s] register output channel: %v", p.name, err)
}
// 会话 GC后台周期回收长期不用的会话
go p.sessionGCLoop()
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12000", Key: "listen", Default: "127.0.0.1:12000",
Type: "string", DisplayName: "监听地址", Type: "string", DisplayName: "监听地址",
@ -45,6 +77,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"}, "agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"}, "query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"}, "timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
}, },
"required": []string{"agent_url", "query"}, "required": []string{"agent_url", "query"},
@ -114,6 +147,66 @@ func (p *Plugin) Stop() error {
return nil return nil
} }
// sessionGCLoop 周期清理超时会话。
func (p *Plugin) sessionGCLoop() {
ticker := time.NewTicker(sessionGCPeriod)
defer ticker.Stop()
for range ticker.C {
p.sessMu.Lock()
for id, sess := range p.sessions {
if time.Since(sess.LastUsed) > 2*time.Hour {
delete(p.sessions, id)
}
}
p.sessMu.Unlock()
}
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
}
// sessionMessages 返回指定会话的近 limit 条消息(时间正序),
// 会话不存在返回 nil。消息格式 [{role, text, ts}]。
func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} {
p.sessMu.Lock()
sess := p.sessions[sessionID]
var hist []string
var lastUsed time.Time
if sess != nil {
hist = append([]string{}, sess.History...)
lastUsed = sess.LastUsed
}
p.sessMu.Unlock()
if sess == nil {
return nil
}
_ = lastUsed
// History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序
start := 0
if len(hist) > limit {
start = len(hist) - limit
}
msgs := make([]map[string]interface{}, 0, len(hist)-start)
for i := start; i < len(hist); i++ {
role, text := "user", hist[i]
if after, ok := strings.CutPrefix(text, "用户: "); ok {
role, text = "user", after
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
role, text = "agent", after
}
msgs = append(msgs, map[string]interface{}{
"role": role,
"text": text,
})
}
return msgs
}
func (p *Plugin) stopServer() { func (p *Plugin) stopServer() {
p.srvMu.Lock() p.srvMu.Lock()
defer p.srvMu.Unlock() defer p.srvMu.Unlock()
@ -137,7 +230,12 @@ func (p *Plugin) startServer(addr string) error {
return fmt.Errorf("listen %s: %v", addr, err) return fmt.Errorf("listen %s: %v", addr, err)
} }
srv := &http.Server{Handler: mux} srv := &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
addrStr := listener.Addr().String() addrStr := listener.Addr().String()
p.srvMu.Lock() p.srvMu.Lock()
@ -186,7 +284,9 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
ID string `json:"id"` ID string `json:"id"`
Method string `json:"method"` Method string `json:"method"`
Params struct { Params struct {
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Message *struct { Message *struct {
Role string `json:"role"` Role string `json:"role"`
Parts []struct { Parts []struct {
@ -210,29 +310,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
} }
queryText = strings.TrimSpace(queryText) queryText = strings.TrimSpace(queryText)
} }
if queryText == "" {
// Inject into agent pipeline via interrupt (preempt current processing) or direct input http.Error(w, "query/message.text required", http.StatusBadRequest)
if queryText != "" { return
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
} }
// Respond with task accepted // 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。
sessionID := strings.TrimSpace(req.Params.SessionID)
injectText := queryText
p.sessMu.Lock()
if sessionID != "" {
sess := p.sessions[sessionID]
if sess == nil {
sess = &a2aSession{ID: sessionID, LastUsed: time.Now()}
p.sessions[sessionID] = sess
}
sess.LastUsed = time.Now()
// 有历史则把上下文拼在前面(截尾防爆量)
if len(sess.History) > 0 {
ctxText := strings.Join(sess.History, "\n")
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText
}
} else {
sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano())
p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()}
}
p.sessMu.Unlock()
// 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、
// 也不再回 202 让请求方永远等不到结果。HTTP 超时由调用方控制。
reply := p.sdk.InjectInputSync(p.name, p.name,
fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name))
// 回复写回会话历史(下一轮作为上下文)
p.sessMu.Lock()
if sess := p.sessions[sessionID]; sess != nil {
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
if len(sess.History) > maxSessionTurns*2 {
sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :]
}
sess.LastUsed = time.Now()
}
p.sessMu.Unlock()
resp := map[string]interface{}{ resp := map[string]interface{}{
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": req.ID, "id": req.ID,
"result": map[string]interface{}{ "result": map[string]interface{}{
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()), "id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
"status": "submitted", "status": "completed",
"session_id": sessionID,
"message": map[string]interface{}{
"role": "agent",
"parts": []map[string]string{{"type": "text", "text": reply}},
},
}, },
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
case "tasks.get": case "tasks.get", "session.get":
// 按 session_id 返回会话内近 N 条消息(默认 10 条)。
sessionID := strings.TrimSpace(req.Params.SessionID)
if sessionID == "" {
sessionID = strings.TrimSpace(req.Params.Query)
}
limit := 10
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
limit = req.Params.Limit
}
msgs := p.sessionMessages(sessionID, limit)
if msgs == nil {
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_id": sessionID,
"status": "not_found",
"messages": []interface{}{},
},
})
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID, "jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"}, "result": map[string]interface{}{
"session_id": sessionID,
"status": "completed",
"messages": msgs,
},
}) })
default: default:
@ -276,9 +443,10 @@ type A2ARequest struct {
} }
type A2AParams struct { type A2AParams struct {
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
Message *A2AMessage `json:"message,omitempty"` SessionID string `json:"session_id,omitempty"`
TaskID string `json:"id,omitempty"` Message *A2AMessage `json:"message,omitempty"`
TaskID string `json:"id,omitempty"`
} }
type A2AResponse struct { type A2AResponse struct {
@ -291,6 +459,7 @@ type A2AResponse struct {
type A2AResult struct { type A2AResult struct {
TaskID string `json:"id,omitempty"` TaskID string `json:"id,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"` Message *A2AMessage `json:"message,omitempty"`
AgentCard *A2AAgentCard `json:"agent_card,omitempty"` AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
} }
@ -356,6 +525,7 @@ func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, er
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
agentURL, _ := args["agent_url"].(string) agentURL, _ := args["agent_url"].(string)
query, _ := args["query"].(string) query, _ := args["query"].(string)
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
timeoutSec := 60 timeoutSec := 60
if v, ok := args["timeout"].(float64); ok && v > 0 { if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v) timeoutSec = int(v)
@ -377,7 +547,8 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()), ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
Method: "tasks.send", Method: "tasks.send",
Params: A2AParams{ Params: A2AParams{
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}}, SessionID: sessionID,
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
}, },
} }
@ -416,10 +587,18 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
replyText = strings.TrimSpace(replyText) replyText = strings.TrimSpace(replyText)
} }
return map[string]interface{}{ result := map[string]interface{}{
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status, "task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
"response": replyText, "response": replyText,
}, nil }
if a2aResp.Result.SessionID != "" || sessionID != "" {
result["session_id"] = a2aResp.Result.SessionID
if result["session_id"] == "" {
result["session_id"] = sessionID
}
result["note"] = "延续会话:下次调用传此 session_id 可保持上下文"
}
return result, nil
} }
// ---- Management Handlers ---- // ---- Management Handlers ----

View File

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

View File

@ -34,8 +34,13 @@ type Plugin struct {
type sessionState struct { type sessionState struct {
ID string ID string
Replying []map[string]interface{} Replying []map[string]interface{}
History []string // 轮次历史 [user, agent, user, agent...],延续上下文用
LastUsed time.Time
} }
// maxSessionTurns 单会话保留的最大轮次对数。
const maxSessionTurns = 10
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
@ -44,6 +49,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sessions = make(map[string]*sessionState) p.sessions = make(map[string]*sessionState)
tp := p.name + "_" tp := p.name + "_"
// 注册自身为输出通道agent 回复 emit 到本通道时有落点。
// (回复主要走同步注入返回,此通道用于 agent 主动 output_send__acp
s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
log.Printf("[%s] channel output: %s", p.name, truncateStr(payload, 120))
return map[string]interface{}{"status": "ok"}, nil
})
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12001", Key: "listen", Default: "127.0.0.1:12001",
Type: "string", DisplayName: "监听地址", Type: "string", DisplayName: "监听地址",
@ -58,6 +71,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000"}, "server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000"},
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"}, "prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"}, "timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
}, },
"required": []string{"server_url", "prompt"}, "required": []string{"server_url", "prompt"},
@ -171,6 +185,7 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
Text string `json:"text"` Text string `json:"text"`
} `json:"request,omitempty"` } `json:"request,omitempty"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Final bool `json:"final,omitempty"` Final bool `json:"final,omitempty"`
} `json:"params,omitempty"` } `json:"params,omitempty"`
} }
@ -190,21 +205,109 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
return return
} }
sid := fmt.Sprintf("session_%d", time.Now().UnixNano()) // 会话:调用方可指定 session_id 延续多轮;不指定则新建。
sid := strings.TrimSpace(req.Params.SessionID)
p.mu.Lock() p.mu.Lock()
p.sessions[sid] = &sessionState{ID: sid} if sid != "" {
if _, exists := p.sessions[sid]; !exists {
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
}
} else {
sid = fmt.Sprintf("session_%d", time.Now().UnixNano())
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
}
st := p.sessions[sid]
p.mu.Unlock() p.mu.Unlock()
if p.sdk != nil { // 延续上下文
p.sdk.InjectInterruptText(p.name, "acp", injectText := text
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, text)) p.mu.Lock()
if len(st.History) > 0 {
ctxText := strings.Join(st.History, "\n")
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + text
} }
p.mu.Unlock()
// 同步注入等待回复:不抢占打断,完整闭环返回文本。
reply := ""
if p.sdk != nil {
reply = p.sdk.InjectInputSync(p.name, p.name,
fmt.Sprintf("[来自ACP Agent的请求 session %s]\n%s\n[注意] 请直接以文本回复本请求,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sid, injectText, p.name))
}
// 写回历史 + 填充 Replying 供 SSE 消费
p.mu.Lock()
st.History = append(st.History, "用户: "+text, "助手: "+reply)
if len(st.History) > maxSessionTurns*2 {
st.History = st.History[len(st.History)-maxSessionTurns*2:]
}
st.LastUsed = time.Now()
if reply != "" {
st.Replying = append(st.Replying, map[string]interface{}{
"type": "reply", "text": reply,
})
}
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID, "jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{ "result": map[string]interface{}{
"session": map[string]interface{}{"id": sid}, "session": map[string]interface{}{"id": sid},
"reply": reply,
},
})
case "session/get":
// 按 session_id 返回会话内近 N 条消息(默认 10 条,时间正序)
sid := req.Params.SessionID
p.mu.RLock()
st := p.sessions[sid]
var hist []string
if st != nil {
hist = append([]string{}, st.History...)
}
p.mu.RUnlock()
if st == nil {
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_id": sid,
"status": "not_found",
"messages": []interface{}{},
},
})
return
}
limit := 10
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
limit = req.Params.Limit
}
start := 0
if len(hist) > limit {
start = len(hist) - limit
}
msgs := make([]map[string]interface{}, 0, len(hist)-start)
for i := start; i < len(hist); i++ {
role, text := "user", hist[i]
if after, ok := strings.CutPrefix(text, "用户: "); ok {
role, text = "user", after
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
role, text = "agent", after
}
msgs = append(msgs, map[string]interface{}{
"role": role,
"text": 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_id": sid,
"status": "completed",
"messages": msgs,
}, },
}) })
@ -212,12 +315,17 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
sid := req.Params.SessionID sid := req.Params.SessionID
p.mu.Lock() p.mu.Lock()
st := p.sessions[sid] 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() p.mu.Unlock()
if st == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if req.Params.Final {
// 客户端结束会话:标记并保留历史(后续可再 session/new 续)
p.mu.Lock()
st.LastUsed = time.Now()
p.mu.Unlock()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
@ -338,6 +446,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
if prompt == "" { if prompt == "" {
return map[string]interface{}{"error": "prompt 不能为空"}, nil return map[string]interface{}{"error": "prompt 不能为空"}, nil
} }
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
timeoutSec := 120 timeoutSec := 120
if v, ok := args["timeout"].(float64); ok && v > 0 { if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v) timeoutSec = int(v)
@ -346,12 +455,16 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
endpoint := serverURL + "/api/session" endpoint := serverURL + "/api/session"
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
params := map[string]interface{}{
"request": map[string]interface{}{"text": prompt},
}
if sessionID != "" {
params["session_id"] = sessionID
}
newBody, _ := json.Marshal(map[string]interface{}{ newBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()), "jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
"method": "session/new", "method": "session/new",
"params": map[string]interface{}{ "params": params,
"request": map[string]interface{}{"text": prompt},
},
}) })
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody)) req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
@ -416,6 +529,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
"session_id": sid, "session_id": sid,
"status": "completed", "status": "completed",
"reply": replyText, "reply": replyText,
"note": "延续会话:下次调用传此 session_id 可保持上下文",
}, nil }, nil
} }

View File

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

View File

@ -5,7 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -21,6 +24,8 @@ type Plugin struct {
provider string provider string
model string model string
size string size string
baseURL string
dataDir string // <data>/ai_images生成本地图片存放目录
} }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -111,10 +116,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key", DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
Category: "ai_image", Secret: true, Category: "ai_image", Secret: true,
}) })
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "", Type: "string",
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081为空走官方 https://api.openai.com",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string", Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability", DisplayName: "Provider", Description: "Image generation provider: openai / stability",
Category: "ai_image", Category: "ai_image",
}) })
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "model", Default: "dall-e-3", Type: "string", Key: "model", Default: "dall-e-3", Type: "string",
@ -131,10 +141,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.provider = getSetting(s.Settings(), "provider", "openai") p.provider = getSetting(s.Settings(), "provider", "openai")
p.model = getSetting(s.Settings(), "model", "dall-e-3") p.model = getSetting(s.Settings(), "model", "dall-e-3")
p.size = getSetting(s.Settings(), "size", "1024x1024") p.size = getSetting(s.Settings(), "size", "1024x1024")
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
// 生图本地存放目录插件专属数据目录SDK DataDir API内核保证存在
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
}
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
tp := p.name + "_" tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{ s.RegisterTool(tp+"generate", sdk.ToolDef{
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.", Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
@ -209,6 +232,14 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
} }
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) { func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
// 上游地址base_url 非空时走自定义网关(如本机 llmsproxy约定不带 /v1 尾缀;
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
endpoint := "https://api.openai.com/v1/images/generations"
if p.baseURL != "" {
base := strings.TrimSuffix(p.baseURL, "/v1")
endpoint = base + "/v1/images/generations"
}
body := openAIReq{ body := openAIReq{
Model: model, Model: model,
Prompt: prompt, Prompt: prompt,
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
ResponseFormat: "url", ResponseFormat: "url",
} }
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
b, _ := json.Marshal(body) b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b)) req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
@ -247,14 +279,74 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
urls[i] = d.URL urls[i] = d.URL
} }
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
localPaths := make([]string, len(urls))
var errs []string
for i, u := range urls {
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
if err != nil {
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
continue
}
localPaths[i] = path
}
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
for _, pth := range localPaths {
if pth != "" {
content += "\n" + pth
}
}
if len(errs) > 0 {
content += "\n\n" + strings.Join(errs, "\n")
}
content += "\n\n已将图片保存到本地不会过期。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
return map[string]interface{}{ return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")), "content": content,
"images": urls, "images": localPaths,
"prompt": prompt, "prompt": prompt,
"model": model, "model": model,
"local_paths": localPaths,
}, nil }, nil
} }
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
dl := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
resp, err := dl.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
ext := ".png"
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
ext = ".jpg"
} else if strings.Contains(ct, "webp") {
ext = ".webp"
}
path := filepath.Join(p.dataDir, baseName+ext)
if err := os.WriteFile(path, data, 0644); err != nil {
return "", err
}
return path, nil
}
type stabilityReq struct { type stabilityReq struct {
TextPrompts []stabilityPrompt `json:"text_prompts"` TextPrompts []stabilityPrompt `json:"text_prompts"`
Width int `json:"width"` Width int `json:"width"`
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
} }
return map[string]interface{}{ return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")), "content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
"images": urls, "images": urls,
"prompt": prompt, "prompt": prompt,
"model": model, "model": model,

View File

@ -2,14 +2,18 @@
"name": "bili", "name": "bili",
"name_zh": "B站视频下载", "name_zh": "B站视频下载",
"name_en": "Bilibili Video Downloader", "name_en": "Bilibili Video Downloader",
"version": "1.1.0", "version": "1.2.0",
"description": "B站视频下载工具基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。", "description": "B站视频下载工具基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["bili", "video", "download"], "tags": [
"bili",
"video",
"download"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -107,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
} }
} }
} }
// 安全校验output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
outputDir = filepath.Clean(outputDir)
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
if outputDir == forbidden {
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
}
}
os.MkdirAll(outputDir, 0755) os.MkdirAll(outputDir, 0755)
var out bytes.Buffer var out bytes.Buffer

View File

@ -2,14 +2,20 @@
"name": "browser", "name": "browser",
"name_zh": "浏览器", "name_zh": "浏览器",
"name_en": "Browser", "name_en": "Browser",
"version": "2.0.0", "version": "2.3.0",
"description": "统一浏览器插件搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", "description": "统一浏览器插件搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["web", "search", "fetch", "browser", "cdp"], "tags": [
"web",
"search",
"fetch",
"browser",
"cdp"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -13,6 +13,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -33,23 +34,49 @@ type Plugin struct {
proxy string proxy string
client *http.Client client *http.Client
sessions map[string]*BrowserSession sessions map[string]*BrowserSession
nextID int nextID int
wg sync.WaitGroup wg sync.WaitGroup
stopCh chan struct{} stopCh chan struct{}
stopOnce sync.Once stopOnce sync.Once
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles空则禁用
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留每个 start 创建一个
// 新标签页CDP Target。同 source 复用自己的标签页。浏览器进程在
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
sharedAllocCtx context.Context
sharedAllocCancel context.CancelFunc
sharedMu sync.Mutex
} }
type BrowserSession struct { type BrowserSession struct {
id string id string
allocCtx context.Context allocCtx context.Context // 共享浏览器进程上下文shared=true 时指向全局单例)
cancel context.CancelFunc cancel context.CancelFunc
ctx context.Context ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time createdAt time.Time
timeout time.Duration timeout time.Duration
closed bool closed bool
mu sync.Mutex mu sync.Mutex
currentURL string currentURL string
shared bool // true=共享浏览器的一个标签页false=独占浏览器实例
profileDir string // 非空表示使用持久化 profile关闭时不删目录
sessionKey string // 共享模式下的复用键agent 来源标识,同 key 复用同一标签页)
}
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_防路径穿越。
func sanitizeProfileName(name string) string {
var b []byte
for _, c := range []byte(name) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
b = append(b, c)
}
}
if len(b) == 0 || string(b) == "." || string(b) == ".." {
return ""
}
return string(b)
} }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -187,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.proxy = readCfg(s.Settings(), "proxy", "") p.proxy = readCfg(s.Settings(), "proxy", "")
p.client = newHTTPClient(p.timeout, p.proxy) p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles")
}
}
tp := p.name + "_" tp := p.name + "_"
cleaner := func(output string) string { cleaner := func(output string) string {
@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{ s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start", Name: tp + "start",
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium支持导航、截图、点击、输入等操作。返回会话 ID。", Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install无法安装时自动降级本地临时模式。同来源复用已有标签页。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL可选"}, "url": map[string]interface{}{"type": "string", "description": "初始导航 URL可选"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m默认 10m)"}, "timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m默认 10m)"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main。同名档案共享登录态与浏览历史不指定则为一次性临时会话"},
}, },
}, },
}, p.handleBrowserStart) }, p.handleBrowserStart)
@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, },
}, p.handleScroll) }, p.handleScroll)
s.RegisterTool(tp+"install", sdk.ToolDef{
Name: tp + "install",
Description: "安装并启动共享浏览器后端homeagent-browser.servicesystemd 托管)。前提:本机已有 chromium 二进制无则先提示用户安装apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleBrowserInstall)
s.RegisterTool(tp+"close", sdk.ToolDef{ s.RegisterTool(tp+"close", sdk.ToolDef{
Name: tp + "close", Name: tp + "close",
Description: "关闭交互式浏览器会话,释放资源。", Description: "关闭交互式浏览器会话,释放资源。",
@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
}, nil }, nil
} }
// handleRender 无头渲染 JS 页面并提取文本normal 模式)。
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
// 后端不可用时 failback 到独立 chromium --dump-dom无登录态仅保功能
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
rawURL := readArg(args, "url", "") rawURL := readArg(args, "url", "")
if rawURL == "" { if rawURL == "" {
@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
return errResult(err.Error()), nil return errResult(err.Error()), nil
} }
waitSec := int64(readArg(args, "wait", float64(0))) waitSec := int64(readArg(args, "wait", float64(0)))
if waitSec > 0 {
time.Sleep(time.Duration(waitSec) * time.Second) var title, html string
rendered := false
ok, needInstall, _ := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
defer remoteCancel()
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
defer tabCancel()
actions := []chromedp.Action{
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
}
if waitSec > 0 {
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
}
actions = append(actions,
chromedp.Title(&title),
chromedp.OuterHTML("html", &html),
)
// 整体限时 30s防慢页拖死工具
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
defer rcancel()
if err := chromedp.Run(rctx, actions...); err == nil {
rendered = true
} else {
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
}
} else if needInstall {
return map[string]interface{}{
"error": "browser backend not installed",
"need_install": true,
"guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)",
}, nil
} }
var html string
chromiumPath := "/usr/local/bin/chromium" if !rendered {
if _, err := os.Stat(chromiumPath); err == nil { chromiumPath := "/usr/local/bin/chromium"
if _, err := os.Stat(chromiumPath); err != nil {
if _, e2 := exec.LookPath("chromium"); e2 == nil {
chromiumPath = "chromium"
} else {
return errResult("no chromium available"), nil
}
}
var out bytes.Buffer var out bytes.Buffer
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
cmd.Stdout = &out cmd.Stdout = &out
if err := cmd.Run(); err != nil { done := make(chan error, 1)
return errResult("chromium: " + err.Error()), nil go func() { done <- cmd.Run() }()
select {
case err := <-done:
if err != nil {
return errResult("chromium: " + err.Error()), nil
}
case <-time.After(30 * time.Second):
cmd.Process.Kill()
<-done // 回收子进程避免僵尸
return errResult("chromium dump-dom timeout (30s)"), nil
} }
html = out.String() html = out.String()
} else {
resp, err := http.Get(rawURL)
if err != nil {
return errResult("http get: " + err.Error()), nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html = string(body)
}
title := ""
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
title = m[1]
} }
text := htmlToText(html) text := htmlToText(html)
origLen := len(text) origLen := len(text)
truncated := origLen > 5000 truncated := origLen > 5000
@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
if truncated { if truncated {
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen) result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
} }
return map[string]interface{}{"content": result, "title": title}, nil mode := "backend-tab"
if !rendered {
mode = "local-dump-dom"
}
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
} }
// ── Interactive Browser Session (CDP) ───────────────────── func cdpReachable(endpoint string) bool {
client := &http.Client{Timeout: 2 * time.Second}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) { resp, err := client.Get(endpoint + "/json/version")
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil { if err != nil {
timeout = 10 * time.Minute return false
} }
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
func systemdUnitInstalled() bool {
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
return err == nil && len(out) > 0
}
// startSystemdUnit 尝试 systemctl start单元已安装但未运行时用
func startSystemdUnit() error {
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
}
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址homeagent-browser.service
const cdpEndpoint = "http://127.0.0.1:9222"
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
// 返回 (ok, needInstall, err)。
func (p *Plugin) ensureBackend() (bool, bool, error) {
if cdpReachable(cdpEndpoint) {
return true, false, nil
}
if systemdUnitInstalled() {
if err := startSystemdUnit(); err == nil {
// 等待 CDP 就绪chromium 启动 ~1-3s
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond)
if cdpReachable(cdpEndpoint) {
return true, false, nil
}
}
}
return false, false, fmt.Errorf("browser backend service installed but failed to start")
}
return false, true, nil // 未安装
}
// sharedTab 在共享后端上开一个新标签页RemoteAllocator + NewContext
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
if err := chromedp.Run(tabCtx); err != nil {
tabCancel()
return nil, nil, err
}
return tabCtx, tabCancel, nil
}
// localSpawnFailback 本地拉起一次性 Chromium离线机器无法装 systemd 服务的兜底)。
// 用临时 profile登录态不跨会话保留——仅保证功能可用。
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:], opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true), chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true), chromedp.Flag("disable-gpu", true),
@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
if p.proxy != "" { if p.proxy != "" {
opts = append(opts, chromedp.Flag("proxy-server", p.proxy)) opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
} }
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, _ := chromedp.NewContext(allocCtx) ctx, _ := chromedp.NewContext(allocCtx)
// 立即分配浏览器和 Target确保后续 Run 的 timeout context 不会杀死浏览器进程
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
if err := chromedp.Run(ctx); err != nil { if err := chromedp.Run(ctx); err != nil {
cancel() cancelAlloc()
return errResult("browser init failed: " + err.Error()), nil return nil, nil, nil, err
}
return allocCtx, cancelAlloc, nil, nil
}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
timeout = 10 * time.Minute
} }
session := &BrowserSession{ source := readArg(args, "source", "")
allocCtx: allocCtx, if source == "" {
cancel: cancel, source = "default"
ctx: ctx, }
createdAt: time.Now(),
timeout: timeout, // 同 source 复用已有标签页
p.mu.Lock()
for _, s := range p.sessions {
if s.shared && s.sessionKey == source && !s.closed {
s.mu.Lock()
id := s.id
cur := s.currentURL
s.mu.Unlock()
p.mu.Unlock()
return map[string]interface{}{
"id": id,
"status": "reused",
"url": cur,
"note": "已复用本来源的现有标签页(登录态全机共享)",
}, nil
}
}
p.mu.Unlock()
var session *BrowserSession
// 路径一systemd 托管的共享后端(主路径)
ok, needInstall, berr := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
probe, _ := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(probe); err != nil {
remoteCancel()
return errResult("connect to browser backend failed: " + err.Error()), nil
}
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(tabCtx); err != nil {
remoteCancel()
return errResult("open tab failed: " + err.Error()), nil
}
session = &BrowserSession{
allocCtx: remoteCtx,
cancel: tabCancel,
ctx: tabCtx,
createdAt: time.Now(),
timeout: timeout,
shared: true,
sessionKey: source,
}
} else if needInstall {
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
"需要本机有 chromium 二进制apt install chromium 或等价命令)," +
"插件会注册 homeagent-browser.service 并启动。" +
"若本机无法联网安装 chromium可继续用本地临时模式重试 browser_start 即自动降级)。"
return map[string]interface{}{
"error": "backend not installed",
"need_install": true,
"guide": guide,
}, nil
} else {
return errResult("browser backend error: " + berr.Error()), nil
} }
p.mu.Lock() p.mu.Lock()
@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
initURL := readArg(args, "url", "") initURL := readArg(args, "url", "")
if initURL != "" { if initURL != "" {
if err := chromedp.Run(ctx, if err := chromedp.Run(session.ctx,
chromedp.Navigate(initURL), chromedp.Navigate(initURL),
chromedp.WaitReady("body"), chromedp.WaitReady("body"),
); err != nil { ); err != nil {
@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
return errResult("navigate failed: " + err.Error()), nil return errResult("navigate failed: " + err.Error()), nil
} }
session.currentURL = initURL session.currentURL = initURL
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
} }
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout) log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
return map[string]interface{}{ return map[string]interface{}{
"id": id, "id": id,
"status": "created", "status": "created",
"mode": "shared-backend",
"url": initURL, "url": initURL,
"timeout": timeout.String(), "timeout": timeout.String(),
}, nil }, nil
@ -1019,3 +1217,104 @@ func (p *Plugin) cleanupLoop() {
} }
} }
} }
// ── browser_install安装 systemd 托管的共享浏览器后端 ──────────
// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。
// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。
func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) {
if cdpReachable(cdpEndpoint) {
return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil
}
// 探测 chromium 二进制
chromePath := ""
for _, c := range []string{
"/usr/bin/chromium", "/usr/bin/chromium-browser",
"/usr/local/bin/chromium", "/usr/bin/google-chrome",
} {
if _, err := os.Stat(c); err == nil {
chromePath = c
break
}
}
if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" {
chromePath = out
} else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" {
chromePath = out
}
if chromePath == "" {
return map[string]interface{}{
"error": "chromium binary not found",
"hint": "请先安装 chromiumapt install chromium 或等价命令,然后重试 browser_install",
}, nil
}
profileDir := ""
if p.profilesDir != "" {
profileDir = filepath.Join(p.profilesDir, "shared")
os.MkdirAll(profileDir, 0755)
} else {
// profilesDir 未注入(无 data_dir退到 /var/lib/homeagent-browser
profileDir = "/var/lib/homeagent-browser"
os.MkdirAll(profileDir, 0755)
}
unit := fmt.Sprintf(`[Unit]
Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222)
After=network.target
[Service]
Type=simple
ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
`, chromePath, profileDir)
unitPath := "/etc/systemd/system/homeagent-browser.service"
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return map[string]interface{}{
"error": "write unit failed (need root): " + err.Error(),
"hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds",
"manual_cmds": []string{
"sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF",
"sudo systemctl daemon-reload",
"sudo systemctl enable --now homeagent-browser.service",
},
}, nil
}
for _, cmd := range [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "homeagent-browser.service"},
} {
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("%v: %s", cmd, string(out)),
}, nil
}
}
// 等待 CDP 就绪
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
if cdpReachable(cdpEndpoint) {
guide := "共享浏览器后端已就绪CDP " + cdpEndpoint + ")。\n" +
"全机共享说明:本机所有 agentHomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" +
"登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" +
"- HomeAgent 内部browser_start 即自动连接本后端\n" +
"- 其他 agent让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect\n" +
"- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir
log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir)
return map[string]interface{}{
"status": "installed",
"endpoint": cdpEndpoint,
"chrome": chromePath,
"profile": profileDir,
"guide": guide,
}, nil
}
}
return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil
}

View File

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

View File

@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
NextEventID: p.nextEventID, NextEventID: p.nextEventID,
} }
b, _ := json.MarshalIndent(data, "", " ") b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.eventsFile(), b, 0644) atomicWriteJSON(p.eventsFile(), b)
} }
// --- Helper: parse remind_before --- // --- Helper: parse remind_before ---
@ -1177,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
} }
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
} }
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -76,9 +76,13 @@ function plugin.start(sdk)
return nil return nil
end, "own_tools") end, "own_tools")
-- 阶段钩子:全局作用域 -- 阶段钩子:全局作用域(修改 ctx 字段会写回内核,见 applyLuaStageResult
sdk.register_stage("pre_action", function(ctx) sdk.register_stage("pre_action", function(ctx)
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id)) 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 return nil
end) end)

View File

@ -2,14 +2,18 @@
"name": "memo", "name": "memo",
"name_zh": "备忘录", "name_zh": "备忘录",
"name_en": "Memo", "name_en": "Memo",
"version": "1.0.0", "version": "1.1.0",
"description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。", "description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["memo", "todo", "notes"], "tags": [
"memo",
"todo",
"notes"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": true,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() {
"next_id": p.nextTID, "next_id": p.nextTID,
}, "", " ") }, "", " ")
p.mu.RUnlock() p.mu.RUnlock()
os.WriteFile(p.todoPath, data, 0644) atomicWriteJSON(p.todoPath, data)
} }
func (p *Plugin) saveMemos() { func (p *Plugin) saveMemos() {
@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() {
"next_id": p.nextMID, "next_id": p.nextMID,
}, "", " ") }, "", " ")
p.mu.RUnlock() p.mu.RUnlock()
os.WriteFile(p.memoPath, data, 0644) atomicWriteJSON(p.memoPath, data)
} }
// ── 待办:未完成计数与提醒 ── // ── 待办:未完成计数与提醒 ──
@ -500,3 +500,12 @@ func (p *Plugin) cleanupData() {
os.Remove(p.memoPath) os.Remove(p.memoPath)
} }
} }
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -2,14 +2,17 @@
"name": "qq", "name": "qq",
"name_zh": "QQ消息", "name_zh": "QQ消息",
"name_en": "qq", "name_en": "qq",
"version": "1.0.0", "version": "1.2.0",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接", "description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["qq", "messaging"], "tags": [
"qq",
"messaging"
],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": false, "bundle": false,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -107,13 +107,149 @@ type Plugin struct {
downloadTasks []*DownloadTask downloadTasks []*DownloadTask
typingMu sync.Mutex typingMu sync.Mutex
typingMap map[int64]*typingState typingMap map[int64]*typingState
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
} }
type typingState struct { type typingState struct {
userID int64 userID int64
stopCh chan struct{} stopCh chan struct{}
} }
// msgRef 一条已见过的消息的引用:只记录 msg_id → (peer, time) 映射,不缓存正文。
// 用途NapCat get_msg 的临时短号 <7 天失效时,据此把 get_msg 兜底为按 peer 拉 get_history。
type msgRef struct {
peerID int64
isGroup bool
time int64 // 秒级时间戳
}
// chatMeta 一个会话(群/私聊)的最新状态,供 list_chats 展示。
// 只维护最新一条的短摘要≤qqLastSumLen 字符)与未读数,不缓存完整历史。
type chatMeta struct {
peerID int64
isGroup bool
name string
unread int
lastTime int64
lastText string
lastNick string
}
const qqMsgTTL = 7 * 86400 // 7 天msg_id → peer 映射的有效期
const qqLastSumLen = 60 // list_chats 里最新一条摘要的最大长度
// snapshotMsg 记录一条策略允许的消息:更新 msg_id→peer 映射与会话未读/最新状态。
// 不缓存消息正文(仅最新一条留 ≤qqLastSumLen 的摘要供列表展示)。
func (p *Plugin) snapshotMsg(msgID, peerID int64, isGroup bool, t int64, nickname, text string) {
if msgID <= 0 {
return
}
p.msgMu.Lock()
defer p.msgMu.Unlock()
// msg_id 映射7 天 TTL惰性清理
p.msgMap[msgID] = msgRef{peerID: peerID, isGroup: isGroup, time: t}
now := time.Now().Unix()
if len(p.msgMap) > 2000 { // 定期清理过期项
for k, v := range p.msgMap {
if now-v.time > qqMsgTTL {
delete(p.msgMap, k)
}
}
}
ch := p.chats[peerID]
if ch == nil {
ch = &chatMeta{peerID: peerID, isGroup: isGroup}
p.chats[peerID] = ch
}
if ch.name == "" {
if isGroup {
ch.name = fmt.Sprintf("群%d", peerID)
} else {
ch.name = nickname
}
}
// 按到达次序维护未读与最新摘要:仅当本条更新时才更新 lastTime/lastText保持按时间排
if t > ch.lastTime {
ch.lastTime = t
ch.lastText = text
ch.lastNick = nickname
}
ch.unread++
}
// lookupMsgRef 查 msg_id 映射,返回 (peer, isGroup, time, ok)。超过 7 天视为无效(交给 get_history
func (p *Plugin) lookupMsgRef(msgID int64) (int64, bool, int64, bool) {
p.msgMu.Lock()
defer p.msgMu.Unlock()
ref, ok := p.msgMap[msgID]
if !ok {
return 0, false, 0, false
}
now := time.Now().Unix()
if now-ref.time > qqMsgTTL {
delete(p.msgMap, msgID)
return 0, false, 0, false
}
return ref.peerID, ref.isGroup, ref.time, true
}
// markChatRead 清零某会话未读数(模型处理完该会话后调用)。
func (p *Plugin) markChatRead(peerID int64) {
p.msgMu.Lock()
defer p.msgMu.Unlock()
if ch := p.chats[peerID]; ch != nil {
ch.unread = 0
}
}
// listChats 返回会话列表:按最新消息时间降序,含未读数与最新一条摘要。
func (p *Plugin) listChats(capN int) []map[string]interface{} {
p.msgMu.Lock()
list := make([]*chatMeta, 0, len(p.chats))
for _, c := range p.chats {
list = append(list, c)
}
p.msgMu.Unlock()
// 降序(最新消息在前)
for i := 1; i < len(list); i++ {
for j := i; j > 0 && list[j].lastTime > list[j-1].lastTime; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
if len(list) > capN {
list = list[:capN]
}
out := make([]map[string]interface{}, 0, len(list))
for _, c := range list {
typ := "private"
if c.isGroup {
typ = "group"
}
item := map[string]interface{}{
"peer_id": c.peerID,
"type": typ,
"name": c.name,
"unread": c.unread,
"last_text": c.lastText,
"last_nick": c.lastNick,
}
if c.lastTime > 0 {
item["last_time"] = time.Unix(c.lastTime, 0).Format("2006-01-02 15:04")
}
out = append(out, item)
}
return out
}
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
@ -150,6 +286,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.httpClient = &http.Client{Timeout: 30 * time.Second} p.httpClient = &http.Client{Timeout: 30 * time.Second}
// msg_id → peer 映射 + 会话状态(不缓存正文)
p.msgMap = make(map[int64]msgRef)
p.chats = make(map[int64]*chatMeta)
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s // 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s
p.fetchBotInfo() p.fetchBotInfo()
if p.botID == 0 { if p.botID == 0 {
@ -248,6 +388,27 @@ type 枚举: text文字/ voice语音转文字后发送/ image
}, },
}, p.handleGetHistory) }, p.handleGetHistory)
p.regTool(s, sdk.ToolDef{
Name: tp + "list_chats", Description: "获取QQ会话列表与真人客户端一致按最新消息先后排序每条标注会话群/私聊)、会话名、未读消息数、最新一条消息摘要与时间。用于发现有未读消息的会话,再配合 qq_get_history 拉取对应会话内容、output_send__qq 回复。",
NoMemory: false,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"count": map[string]interface{}{"type": "integer", "description": "最多返回会话数默认10"},
}, "required": []string{},
},
}, p.handleListChats)
p.regTool(s, sdk.ToolDef{
Name: tp + "mark_read", Description: "将某个会话的未读计数清零(对象:群聊传 group_id私聊传 user_id。处理完某会话消息后可调用让 list_chats 的未读数回到0与真人客户端标记已读一致。",
NoMemory: false,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"},
}, "required": []string{},
},
}, p.handleMarkRead)
// ---- 查询 ---- // ---- 查询 ----
p.regTool(s, sdk.ToolDef{ p.regTool(s, sdk.ToolDef{
Name: tp + "get_groups", Description: "获取QQ群列表可按关键词搜索群名", Name: tp + "get_groups", Description: "获取QQ群列表可按关键词搜索群名",
@ -739,6 +900,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
// 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃)
if evt.MessageType == "group" {
log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s",
evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage)
}
rawCQ := evt.RawMessage rawCQ := evt.RawMessage
text := rawCQ text := rawCQ
@ -763,9 +929,32 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} }
if evt.MessageType == "group" { if evt.MessageType == "group" {
if !p.isGroupAllowed(evt.GroupID) { if !p.isGroupAllowed(evt.GroupID) {
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
}
// ---- 记录 msg_id→peer 映射与会话状态(不缓存正文,仅最新一条短摘要)----
// 策略允许的消息(群/私聊、是否 @bot 均记),供 get_msg 兜底与 list_chats 使用;
// @bot 与否只决定是否发中断,不影响记录——与真人客户端一致看到全部会话。
{
peerID, isGroup := evt.UserID, false
if evt.MessageType == "group" {
peerID, isGroup = evt.GroupID, true
}
sum := text
runes := []rune(sum)
if len(runes) > qqLastSumLen {
sum = string(runes[:qqLastSumLen]) + "…"
}
if evt.Time == 0 {
evt.Time = time.Now().Unix()
}
p.snapshotMsg(evt.MessageID, peerID, isGroup, evt.Time, nickname, sum)
}
if evt.MessageType == "group" {
// 群消息必须 @ 机器人才响应 // 群消息必须 @ 机器人才响应
if p.botID == 0 { if p.botID == 0 {
log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID) log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID)
@ -773,6 +962,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
return return
} }
if !p.isAtBot(evt.Message) { if !p.isAtBot(evt.Message) {
// 诊断:@ 解析失败时打印 at 段原文与 botID定位漏报问题
log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)",
evt.GroupID, evt.UserID, p.botID, rawCQ)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
@ -782,9 +974,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
outputTool := "output_send__" + p.name outputTool := "output_send__" + p.name
var interrupt string var interrupt string
if evt.MessageType == "group" { if evt.MessageType == "group" {
interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。如果消息包含引用回复,使用%sget_history(group_id=%d)查看上下文。使用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, outputTool) interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(group_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, tp, outputTool)
} else { } else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool) interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
} }
if p.isAdmin(evt.UserID) { if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt interrupt = "【重要!老大消息】" + interrupt
@ -810,6 +1002,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
if evt.GroupID == rule.GroupID { if evt.GroupID == rule.GroupID {
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
go func(r ForwardRule, msg string) { go func(r ForwardRule, msg string) {
defer func() { _ = recover() }()
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
} }
@ -831,6 +1024,126 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
// ======== Tool Handlers ======== // ======== Tool Handlers ========
// getMsgFromHistoryByTime 按 (peer, isGroup, targetTime) 从 NapCat 拉最近历史,返回距 targetTime 最近的完整消息。
func (p *Plugin) getMsgFromHistoryByTime(peerID int64, isGroup bool, targetTime int64) (map[string]interface{}, bool) {
ep := "get_friend_msg_history"
params := map[string]interface{}{"user_id": peerID, "count": 50}
if isGroup {
ep = "get_group_msg_history"
params = map[string]interface{}{"group_id": peerID, "count": 50}
}
raw, err := p.napcat(ep, params)
if err != nil {
return nil, false
}
rawStr, _ := rawString(raw)
if rawStr == "" {
return nil, false
}
var resp struct {
Data *struct {
Messages []interface{} `json:"messages"`
} `json:"data"`
}
if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil {
return nil, false
}
var best map[string]interface{}
bestAbs := int64(-1)
for _, m := range resp.Data.Messages {
mm, ok := m.(map[string]interface{})
if !ok {
continue
}
mt, _ := mm["time"].(float64)
t := int64(mt)
if t == 0 {
continue
}
abs := t - targetTime
if abs < 0 {
abs = -abs
}
if bestAbs < 0 || abs < bestAbs {
bestAbs = abs
best = mm
}
}
if best == nil {
return nil, false
}
return best, true
}
// msgToGetMsgResult 把一条 NapCat 历史消息对象转成与 get_msg 同构的结果(历史包装语义)。
func msgToGetMsgResult(msg map[string]interface{}) map[string]interface{} {
nickname := ""
if s, ok := msg["sender"].(map[string]interface{}); ok {
if n, _ := s["nickname"].(string); n != "" {
nickname = n
}
if c, _ := s["card"].(string); c != "" {
nickname = c
}
}
rawText, _ := msg["raw_message"].(string)
content := rawText
if content == "" {
if segs, ok := msg["message"].([]interface{}); ok {
var parts []string
for _, seg := range segs {
segMap, _ := seg.(map[string]interface{})
if segMap == nil {
continue
}
typ, _ := segMap["type"].(string)
segData, _ := segMap["data"].(map[string]interface{})
if segData == nil {
continue
}
switch typ {
case "text":
if t, _ := segData["text"].(string); t != "" {
parts = append(parts, t)
}
case "image":
parts = append(parts, "[图片]")
case "file":
if n, _ := segData["name"].(string); n != "" {
parts = append(parts, "[文件:"+n+"]")
}
default:
if typ != "" {
parts = append(parts, "["+typ+"]")
}
}
}
if len(parts) > 0 {
content = strings.Join(parts, " ")
}
}
}
mid, _ := msg["message_id"].(float64)
uid, _ := msg["user_id"].(float64)
gid, _ := msg["group_id"].(float64)
mt, _ := msg["time"].(float64)
mtType, _ := msg["message_type"].(string)
loc := "私聊"
if mtType == "group" || gid > 0 {
loc = "群聊"
}
return map[string]interface{}{
"content": content,
"message_id": int64(mid),
"user_id": int64(uid),
"group_id": int64(gid),
"nickname": nickname,
"message_type": mtType,
"type": loc,
"time": time.Unix(int64(mt), 0).Format("2006-01-02 15:04:05"),
}
}
func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) {
msgID, err := convInt64(args["message_id"]) msgID, err := convInt64(args["message_id"])
if err != nil { if err != nil {
@ -840,10 +1153,24 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
}, nil }, nil
} }
// 本地 msg_id→peer 映射命中且 <7 天 → 用 get_history 语义兜底NapCat 临时短号失效也不怕)
if peerID, isGroup, t, ok := p.lookupMsgRef(msgID); ok {
if m, found := p.getMsgFromHistoryByTime(peerID, isGroup, t); found {
// 找到同会话、时间最接近的消息,包装为 get_msg 同构返回
res := msgToGetMsgResult(m)
res["resolved_via"] = "history" // 标明由历史查询兜底
return res, nil
}
// 历史窗口内没找到(消息可能被裁剪/更早),回退 NapCat 原查询
}
return p.getMsgFromNapcat(msgID)
}
func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) {
raw, err := p.napcat("get_msg", map[string]interface{}{"message_id": msgID}) raw, err := p.napcat("get_msg", map[string]interface{}{"message_id": msgID})
if err != nil { if err != nil {
return map[string]interface{}{ return map[string]interface{}{
"content": fmt.Sprintf("查询 NapCat 失败: %s", err), "content": fmt.Sprintf("查询 NapCat 失败: %s。该 message_id 可能已过期,请改用 qq_get_history 按会话拉取最近消息(或用 qq_list_chats 看未读会话)", err),
"message_id": msgID, "message_id": msgID,
"not_found": true, "not_found": true,
}, nil }, nil
@ -874,7 +1201,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
} }
if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil { if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil {
return map[string]interface{}{ return map[string]interface{}{
"content": "解析 NapCat 响应失败", "content": "解析 NapCat 响应失败(消息可能已过期)。请改用 qq_get_history 按会话拉取最近消息,或用 qq_list_chats 查看未读会话",
"message_id": msgID, "message_id": msgID,
"not_found": true, "not_found": true,
}, nil }, nil
@ -980,6 +1307,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
// 异步标记已读 // 异步标记已读
go func() { go func() {
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
if d.MessageType == "group" && d.GroupID > 0 { if d.MessageType == "group" && d.GroupID > 0 {
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID}) p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
} else if d.UserID > 0 { } else if d.UserID > 0 {
@ -1070,20 +1398,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
} }
return p.napcat("send_private_msg", msg) return p.napcat("send_private_msg", msg)
case "image": case "image", "file":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)} // 收敛到 output 通道payload 支持本地路径或 http(s) URL。
if groupID != 0 { // 本地路径拷入 NapCat 共享目录转 file:// URI与 voice 分支同模式),
msg["group_id"] = groupID // 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
} else { uri := payload
msg["user_id"] = userID if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
!strings.HasPrefix(payload, "file://") {
if _, err := os.Stat(payload); err != nil {
return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload)
}
os.MkdirAll(p.remoteDir, 0755)
dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload)))
data, err := os.ReadFile(payload)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
if err := os.WriteFile(dest, data, 0644); err != nil {
return nil, fmt.Errorf("写入共享目录失败: %w", err)
}
uri = "file:///app/files/" + filepath.Base(dest)
} }
if groupID != 0 { cqTag := "file"
return p.napcat("send_group_msg", msg) if rawType == "image" {
cqTag = "image"
} }
return p.napcat("send_private_msg", msg) msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
case "file":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
if groupID != 0 { if groupID != 0 {
msg["group_id"] = groupID msg["group_id"] = groupID
} else { } else {
@ -1191,6 +1531,31 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
return p.napcat("send_private_msg", params) return p.napcat("send_private_msg", params)
} }
func (p *Plugin) handleListChats(args map[string]interface{}) (interface{}, error) {
count := 10
if c, err := convInt64(args["count"]); err == nil && c > 0 && c < 100 {
count = int(c)
}
chats := p.listChats(count)
return map[string]interface{}{
"chats": chats,
"total": len(chats),
"hint": "按最新消息先后排序unread 为该会话未读消息数,处理完用 qq_mark_read 清零;用 qq_get_history(group_id/user_id) 拉取会话内容",
}, nil
}
func (p *Plugin) handleMarkRead(args map[string]interface{}) (interface{}, error) {
if gid, err := convInt64(args["group_id"]); err == nil {
p.markChatRead(gid)
return map[string]interface{}{"status": "ok", "group_id": gid, "unread": 0}, nil
}
if uid, err := convInt64(args["user_id"]); err == nil {
p.markChatRead(uid)
return map[string]interface{}{"status": "ok", "user_id": uid, "unread": 0}, nil
}
return nil, fmt.Errorf("need group_id or user_id")
}
func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) {
gid, gerr := convInt64(args["group_id"]) gid, gerr := convInt64(args["group_id"])
uid, uerr := convInt64(args["user_id"]) uid, uerr := convInt64(args["user_id"])
@ -1329,6 +1694,12 @@ func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, err
if len(files) > 0 { if len(files) > 0 {
result["files"] = files result["files"] = files
} }
// 拉取过某会话历史即视为已读(与真人客户端一致:看过=已读)
if gerr == nil {
p.markChatRead(gid)
} else if uerr == nil {
p.markChatRead(uid)
}
return result, nil return result, nil
} }
@ -1638,7 +2009,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
return resp, nil return resp, nil
} }
dlURL := parsed.Data.URL dlURL := parsed.Data.URL
httpResp, err := http.Get(dlURL) client := &http.Client{Timeout: 120 * time.Second}
httpResp, err := client.Get(dlURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("download: %w", err) return nil, fmt.Errorf("download: %w", err)
} }
@ -1693,6 +2065,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
task := p.addDownloadTask(fileID, filename) task := p.addDownloadTask(fileID, filename)
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) { go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
defer func() {
if r := recover(); r != nil {
log.Printf("[qq] download task %s panic: %v", fid, r)
}
}()
savePath := "" savePath := ""
errMsg := "" errMsg := ""
if furl != "" { if furl != "" {

View File

@ -0,0 +1,153 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
var realCfg = "/home/newqqagent/config.db"
var realLog = "/home/newqqagent/log"
func TestDiagTriage(t *testing.T) {
p := &Plugin{name: "recoverydiag"}
cases := []struct {
name string
args map[string]interface{}
want string
}{
{"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"},
{"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"},
{"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"},
{"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"},
{"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"},
}
for _, c := range cases {
r, _ := p.handleTriage(c.args)
m, ok := r.(map[string]interface{})
if !ok {
t.Fatalf("%s: not a map", c.name)
}
if got, _ := m["class"].(string); got != c.want {
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
}
}
}
func TestDiagDB(t *testing.T) {
if _, err := os.Stat(realCfg); err != nil {
t.Skip("config.db not present, skipping")
}
p := &Plugin{name: "recoverydiag"}
r, err := p.handleDB(map[string]interface{}{"db_path": realCfg})
if err != nil {
t.Fatalf("handleDB: %v", err)
}
m := r.(map[string]interface{})
t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"])
if m["integrity"] != "ok" {
t.Errorf("integrity = %v, want ok", m["integrity"])
}
if m["source_count"] == 0 {
t.Errorf("source_count == 0, expected LLM sources")
}
if got, _ := m["source_failed"].(int); got != 0 {
t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"])
}
}
func TestDiagLogScan(t *testing.T) {
if _, err := os.Stat(realLog); err != nil {
t.Skip("log dir not present, skipping")
}
p := &Plugin{name: "recoverydiag"}
r, err := p.handleLogScan(map[string]interface{}{
"log_dir": realLog,
"since_minutes": 60 * 24 * 3,
})
if err != nil {
t.Fatalf("handleLogScan: %v", err)
}
m := r.(map[string]interface{})
t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"])
}
func TestDiagDelta(t *testing.T) {
base := t.TempDir()
cur := t.TempDir()
sub := filepath.Join(base, "sub")
os.MkdirAll(sub, 0755)
// modified: same path, different content
os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644)
os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644)
// created
os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644)
// deleted
os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644)
// unchanged
os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644)
os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644)
p := &Plugin{name: "recoverydiag"}
r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur})
if err != nil {
t.Fatalf("handleDelta: %v", err)
}
m := r.(map[string]interface{})
sum := m["summary"].(map[string]int)
t.Logf("summary=%v total=%v", sum, m["total_diff"])
if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 {
t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum)
}
}
func TestDiagLoc(t *testing.T) {
p := &Plugin{name: "recoverydiag"}
r, _ := p.handleLoc(map[string]interface{}{
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
"db": map[string]interface{}{"verdict": "ok"},
"log_scan": map[string]interface{}{"dominant": "panic"},
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
})
m := r.(map[string]interface{})
// 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径
raw, _ := json.Marshal(m)
var dec map[string]interface{}
json.Unmarshal(raw, &dec)
hs := dec["ranked_hypotheses"].([]interface{})
if len(hs) == 0 {
t.Fatal("no hypotheses")
}
top := hs[0].(map[string]interface{})
t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"])
if top["cause"] != "code_panic_loop" {
t.Errorf("expected code_panic_loop, got %v", top["cause"])
}
}
func TestDiagLocPersist(t *testing.T) {
kb := filepath.Join(t.TempDir(), "recovery_kb")
p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)}
args := map[string]interface{}{
"persist": true,
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
"db": map[string]interface{}{"verdict": "ok"},
"log_scan": map[string]interface{}{"dominant": "panic"},
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
}
if _, err := p.handleLoc(args); err != nil {
t.Fatalf("handleLoc: %v", err)
}
entries, err := os.ReadDir(kb)
if err != nil || len(entries) == 0 {
t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries)
}
data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name()))
if !strings.Contains(string(data), `"cause"`) {
t.Errorf("persisted file missing cause field: %s", data)
}
}

View File

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

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

@ -0,0 +1,21 @@
{
"name": "recoverydiag",
"name_zh": "恢复诊断",
"name_en": "Recovery Diagnostics",
"version": "0.2.0",
"description": "快速检查/崩溃取证工具集diag_triage退出码/信号/存活粗分、diag_dbconfig.db 完整性 + LLM 源解析校验、diag_log_scan日志签名命中、diag_deltalast-good 快照 vs 现状 diff、diag_loc正交综合定位。全部返回结论而非原文确定性、不消耗 LLM token供 guard / failback 恢复决策使用。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"diag",
"recovery",
"diagnostics",
"triage",
"failback"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -467,7 +467,7 @@ func (p *Plugin) saveData() {
SeenGUIDs: p.seenGUIDs, SeenGUIDs: p.seenGUIDs,
} }
b, _ := json.MarshalIndent(data, "", " ") b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.dataFile(), b, 0644) atomicWriteJSON(p.dataFile(), b)
} }
// cleanupData 卸载时清理订阅数据目录feeds.json 等) // cleanupData 卸载时清理订阅数据目录feeds.json 等)
@ -486,3 +486,12 @@ func (p *Plugin) cleanupData() {
} }
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -1,12 +1,15 @@
// Package meta 收集 HomeAgent SDK 的全部元数据。 // Package meta 收集 HomeAgent SDK 的全部元数据。
// 版本号应与核心 meta.Version 保持一致。 // 版本号应与核心 meta.Version 保持一致。
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
package meta package meta
var ( var (
// Version 是 HomeAgent SDK 版本号。 // Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。 // 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
Version = "0.9.0" //
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
// 公开 SDK 接口sdk/ 目录)**零改动**——插件业务代码不需要改一行,
// 但产物形态变了plugin.so → plugin.bin必须用新版 plugindev 重编。
Version = "1.0.0"
// Commit 是构建时的 Git commit hash。 // Commit 是构建时的 Git commit hash。
Commit = "unknown" Commit = "unknown"
@ -21,7 +24,10 @@ var (
CoreModule = "gitcode.com/JianFeeeee/HomeAgent" CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。 // CoreVersion 是此 SDK 所兼容的最低核心版本。
CoreVersion = "0.9.0" //
// 1.0.0 是硬下限而非建议值0.9.x 内核只会 dlopen `.so`
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
CoreVersion = "1.0.0"
) )
// FullVersion 返回完整的版本字符串。 // FullVersion 返回完整的版本字符串。
@ -29,74 +35,15 @@ func FullVersion() string {
return SDKName + " v" + Version + " (" + Commit + ")" return SDKName + " v" + Version + " (" + Commit + ")"
} }
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ---- // ---- 协议版本 ----
// ABI 标识版本直接取内核版本号字符串semver与核心 Version 保持一致,不使用独立数字编码。 //
// 协商层C 结构体 int version 字段)使用 CABINum由版本字符串派生的整数major*100 + minor // 子进程 RPC 的协议版本是一个独立的小整数,与 SDK/内核语义版本解耦:
// 映射v0.8.x → CABINum=800v0.9.x → CABINum=900invoke_stage 写回)。 // 语义版本变动频繁(修 bug、加字段而 wire 协议只在**帧格式或握手语义**
// 小版本patch演进不影响 ABICABINum 不变。version_min 保证旧 ABI 插件仍可加载 // 变化时才升。当前值见核心仓 internal/plugin/proc/protocol.go 的 ProtocolVersion
//
var ( // C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
// ABIVersion 是 ABI 标识版本(字符串 semver与 SDK CoreVersion 对齐)。 // Part 6.2 删除 internal/plugin/cabi/ 一并退场:
ABIVersion = CoreVersion // - 整数 method id 平移为 method 名字符串proc/protocol.go 的 Method* 常量)
// ABIVersionMin 是兼容的最低 ABI 标识版本。 // - 版本协商改为握手帧里的 protocol 字段
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
)

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

@ -114,6 +114,9 @@ type IOInjector interface {
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 // InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。 // 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
InjectInputSync(source, channel, text string) string InjectInputSync(source, channel, text string) string
// SetToolBlocks 插件工具注入多模态内容块image_url/audio_url内核在下一条
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
SetToolBlocks(blocks []ContentBlock)
} }
// EventType identifies the kind of system event. // EventType identifies the kind of system event.
@ -127,6 +130,11 @@ const (
EventReasoning EventType = "reasoning" EventReasoning EventType = "reasoning"
EventStage EventType = "stage" EventStage EventType = "stage"
EventSystem EventType = "system" EventSystem EventType = "system"
// 流式增量事件token 级):核心 process() 流式化后每收到一个增量块发布。
// 客户端可选订做真逐 token 渲染;聚合事件仍照常发布,旧订阅者不受影响。
EventReasoningDelta EventType = "reasoning_delta"
EventContentDelta EventType = "content_delta"
) )
// Event represents a system event published by the kernel. // Event represents a system event published by the kernel.
@ -147,6 +155,17 @@ type EventSubscriber interface {
Subscribe(eventType EventType, handler EventHandler) func() 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. // StageScope controls which events a stage handler receives.
type StageScope int type StageScope int
@ -200,6 +219,7 @@ type PluginSDK struct {
sett SettingsAPI sett SettingsAPI
social SocialAPI social SocialAPI
events EventSubscriber events EventSubscriber
plgMgr PluginMgrAPI
autoRestart bool autoRestart bool
@ -347,6 +367,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social } func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es } 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 ---- // ---- IO Convenience Methods ----
// InjectInterruptText injects a text interrupt that can preempt current LLM processing. // InjectInterruptText injects a text interrupt that can preempt current LLM processing.
@ -436,3 +463,22 @@ func (s *PluginSDK) RunOnRemoveHandlers() {
handlers[i]() handlers[i]()
} }
} }
// ContentBlock 是多模态内容块OpenAI 格式text/image_url/audio_url
// 插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
// 请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
AudioURL *AudioURL `json:"audio_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type AudioURL struct {
URL string `json:"url"`
}

View File

@ -19,6 +19,11 @@ type SettingsAPI interface {
// ListCore lists core config keys matching the prefix. // ListCore lists core config keys matching the prefix.
ListCore(prefix string) ([]string, error) ListCore(prefix string) ([]string, error)
// DataDir returns the plugin-specific data directory (guaranteed to exist):
// <daemon data>/plugin_data/<plugin_name>. Plugins should persist any
// runtime files (generated images, caches, downloads) here.
DataDir() string
// GetPlugin reads another plugin's config table. // GetPlugin reads another plugin's config table.
GetPlugin(plugin, key string) (interface{}, error) GetPlugin(plugin, key string) (interface{}, error)

View File

@ -24,7 +24,8 @@ func cmdBuild(args []string) {
// Read all config from plg.json first // Read all config from plg.json first
plg, err := readPlgJSON("plg.json") plg, err := readPlgJSON("plg.json")
if err != nil { if err != nil {
fmt.Printf("error: read plg.json: %v\n", err); os.Exit(1) fmt.Printf("error: read plg.json: %v\n", err)
os.Exit(1)
} }
// Base config from plg.json // Base config from plg.json
@ -39,11 +40,13 @@ func cmdBuild(args []string) {
switch args[i] { switch args[i] {
case "--outdir": case "--outdir":
if i+1 < len(args) { if i+1 < len(args) {
outDir = args[i+1]; i++ outDir = args[i+1]
i++
} }
case "--target": case "--target":
if i+1 < len(args) { if i+1 < len(args) {
targets = append(targets, args[i+1]); i++ targets = append(targets, args[i+1])
i++
} }
case "--bundle": case "--bundle":
bundle = true bundle = true
@ -51,11 +54,13 @@ func cmdBuild(args []string) {
bundle = false bundle = false
case "--sdk-path": case "--sdk-path":
if i+1 < len(args) { if i+1 < len(args) {
sdkPath = args[i+1]; i++ sdkPath = args[i+1]
i++
} }
case "--replace", "-R": case "--replace", "-R":
if i+1 < len(args) { if i+1 < len(args) {
cliReplaces = append(cliReplaces, args[i+1]); i++ cliReplaces = append(cliReplaces, args[i+1])
i++
} }
} }
} }
@ -68,18 +73,9 @@ func cmdBuild(args []string) {
// Ensure go.mod exists with correct SDK path // Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath) sdkModule := ensureGoMod(plg, sdkPath)
// First build: fetch the SDK module (generates go.sum with zip hash) // 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
if sdkModule != "" { if sdkModule != "" {
if _, err := os.Stat("go.sum"); os.IsNotExist(err) { ensureSDKResolvable(plg, sdkModule, sdkPath)
dl := exec.Command("go", "mod", "download", sdkModule)
dl.Env = os.Environ()
dl.Stdout = os.Stdout
dl.Stderr = os.Stderr
fmt.Println(" downloading SDK module deps...")
if err := dl.Run(); err != nil {
fmt.Printf(" error: go mod download: %v\n", err)
}
}
} }
// Merge plg.json replaces + CLI overrides // Merge plg.json replaces + CLI overrides
@ -105,14 +101,17 @@ func cmdBuild(args []string) {
} }
// allBundleTargets 是 --bundle 模式构建的全部平台。 // allBundleTargets 是 --bundle 模式构建的全部平台。
// 每个 OS 只有一个架构amd64避免二进制文件名冲突。 //
// 子进程模式下各平台产物同名plugin.bin——进程边界即 ABI 边界,
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
var allBundleTargets = []struct { var allBundleTargets = []struct {
target string target string
entry string // 二进制在 zip 中的文件名 entry string // 二进制在 zip 中的文件名
}{ }{
{"linux/amd64", "plugin.so"}, {"linux/amd64", "plugin.bin.linux.amd64"},
{"darwin/amd64", "plugin.dylib"}, {"darwin/amd64", "plugin.bin.darwin.amd64"},
{"windows/amd64", "plugin.dll"}, {"windows/amd64", "plugin.bin.windows.amd64"},
} }
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) { func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
@ -120,9 +119,13 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
buildDir := "build" buildDir := "build"
os.MkdirAll(buildDir, 0755) os.MkdirAll(buildDir, 0755)
// Auto-generate C ABI bridge for non-Windows runtimeCleanup, err := generateProcRuntime()
bridgeCleanup := generateBridge("") if err != nil {
defer bridgeCleanup() fmt.Printf(" error: %v\n", err)
return
}
defer runtimeCleanup()
thirdpartCleanup := linkThirdpart(plg, "linux/amd64") thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
defer thirdpartCleanup() defer thirdpartCleanup()
@ -135,22 +138,18 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
return return
} }
outPath := filepath.Join(buildDir, cfg.entryFile) // 每平台产物落到独立路径,避免相互覆盖
outName := fmt.Sprintf("%s_%s_%s", cfg.entryFile, cfg.goos, cfg.goarch)
outPath := filepath.Join(buildDir, outName)
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath) // 零 cgo跨平台交叉编译不需目标平台 C 工具链
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ() cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1") cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
if cfg.goos == "windows" {
cc := detectWindowsCC()
if cc != "" {
cmd.Env = append(cmd.Env, "CC="+cc)
}
}
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err) fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
return return
@ -168,7 +167,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
for p := range platforms { for p := range platforms {
plats = append(plats, p) plats = append(plats, p)
} }
writePluginJSON(plg, plats, "plugin.so") writePluginJSON(plg, plats, procEntryFile)
// package single .hmap with correctly named entries // package single .hmap with correctly named entries
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn))) hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
@ -176,7 +175,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath)) fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
} }
func (p *PlgConfig) IsLua() bool { return p.Entry == "main.lua" } // IsLua 判断是否为 Lua 插件(走解释器,不经过 Go 编译)。
//
// 这是 entry 字段唯一仍在使用的用途Go 插件不再看 entry 值,一律产出 plugin.bin。
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
func readPlgJSON(path string) (*PlgConfig, error) { func readPlgJSON(path string) (*PlgConfig, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@ -228,9 +230,14 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
type buildConfig struct { type buildConfig struct {
goos string goos string
goarch string goarch string
entryFile string // "plugin.so" or "plugin.dll" entryFile string // 一律为 plugin.bin进程边界即 ABI 边界,无平台特有扩展名)
} }
// resolveBuild 解析目标平台。
//
// 全平台统一产出 plugin.bin子进程模式下不存在 .so/.dylib/.dll 的区分,
// 因为进程边界本身就是 ABI 边界——这正是三套独立 ABI 实现收敛为
// 单一 RPC 实现的直接后果§9.2Windows 不再是能力退化的第三套实现)。
func resolveBuild(target string) (*buildConfig, string) { func resolveBuild(target string) (*buildConfig, string) {
if target == "lua" || target == "" { if target == "lua" || target == "" {
return nil, "lua" return nil, "lua"
@ -245,14 +252,8 @@ func resolveBuild(target string) (*buildConfig, string) {
} }
switch goos { switch goos {
case "linux": case "linux", "darwin", "freebsd", "windows":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, "" return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
case "darwin":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.dylib"}, ""
case "freebsd":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, ""
case "windows":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.dll"}, ""
default: default:
return nil, fmt.Sprintf("unsupported OS %q", goos) return nil, fmt.Sprintf("unsupported OS %q", goos)
} }
@ -333,6 +334,128 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string {
return sdkModule return sdkModule
} }
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
//
// 为何需要这个函数gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目go build 就报
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
// 同一个错误上——新用户拿到的是两段无关的报错。
//
// 三级策略,按代价递增:
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做replace 到目录时
// go 不需要也不校验 go.sum
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目go.mod 旧、
// 无 replace的救场路径。
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod让它自己去试失败则给
// 可操作的提示而不是让用户去猜。
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
// 策略 1已有指向本地目录的 replace。
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
target := parts[3]
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
return // 已指向本地目录,无需 go.sum
}
}
// 策略 2能定位到本机 SDK 就写 replace。
// resolveSDKPath 失败会 os.Exit所以只在能确定拿到路径时调用它背后的探测。
if root := findLocalSDK(sdkPath); root != "" {
if appendGoModReplace(sdkModule, root) {
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace%s\n", root)
return
}
}
// 策略 3交给 go mod tidy。
if _, err := os.Stat("go.sum"); err == nil {
return // 已有 go.sum不插手
}
fmt.Println(" 解析 SDK 依赖go mod tidy...")
tidy := exec.Command("go", "mod", "tidy")
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := tidy.CombinedOutput(); err != nil {
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
if len(out) > 0 {
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
}
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK\n", sdkModule)
fmt.Printf(" plugindev sdk install latest # 装一份到 ~/.homeagent/plugindev/sdk\n")
fmt.Printf(" plugindev build --sdk-path <路径> # 或直接指定源码目录\n")
}
}
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
//
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit适合“必须有”的调用点
// 这里是“有则更好”的探测,不能把构建搞挂。
func findLocalSDK(sdkPath string) string {
candidates := []string{}
if sdkPath != "" {
if abs, err := filepath.Abs(sdkPath); err == nil {
candidates = append(candidates, abs)
}
}
// plugindev 自身所在位置往上三级tools/plugindev/plugindev → SDK 根)
if self, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
}
// plugindev sdk use 选定的版本
store := os.Getenv("HOMEAGENT_SDK_DIR")
if store == "" {
if home, err := os.UserHomeDir(); err == nil {
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
}
}
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
if ver := strings.TrimSpace(string(d)); ver != "" {
candidates = append(candidates, filepath.Join(store, ver))
}
}
}
for _, c := range candidates {
if c == "" {
continue
}
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
return c
}
}
return ""
}
// appendGoModReplace 向 go.mod 追加一条 replace成功返回 true。
func appendGoModReplace(module, localPath string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
abs, err := filepath.Abs(localPath)
if err != nil {
return false
}
abs = strings.ReplaceAll(abs, "\\", "/")
s := strings.TrimRight(string(data), "\r\n")
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
return os.WriteFile("go.mod", []byte(s), 0644) == nil
}
func resolveSDKPath(sdkPath string) string { func resolveSDKPath(sdkPath string) string {
if sdkPath != "" { if sdkPath != "" {
abs, _ := filepath.Abs(sdkPath) abs, _ := filepath.Abs(sdkPath)
@ -403,7 +526,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
return return
} }
// Resolve build config // Resolve build config(全平台统一产出 plugin.bin
cfg, errMsg := resolveBuild(target) cfg, errMsg := resolveBuild(target)
if cfg == nil { if cfg == nil {
fmt.Printf(" error: %s\n", errMsg) fmt.Printf(" error: %s\n", errMsg)
@ -414,9 +537,12 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
os.MkdirAll(buildDir, 0755) os.MkdirAll(buildDir, 0755)
outPath := filepath.Join(buildDir, cfg.entryFile) outPath := filepath.Join(buildDir, cfg.entryFile)
// Auto-generate C ABI bridge (all platforms use c-shared) runtimeCleanup, err := generateProcRuntime()
bridgeCleanup := generateBridge(cfg.goos) if err != nil {
defer bridgeCleanup() fmt.Printf(" error: %v\n", err)
return
}
defer runtimeCleanup()
// Auto-link thirdpart/ contents + source_dirs + replace targets // Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target) thirdpartCleanup := linkThirdpart(plg, target)
@ -425,28 +551,15 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
// Write plugin.json with the correct entry for this target // Write plugin.json with the correct entry for this target
writePluginJSON(plg, nil, cfg.entryFile) writePluginJSON(plg, nil, cfg.entryFile)
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath) // 普通 go build + 零 cgo交叉编译不再需要目标平台的 C 工具链
// (旧路径靠 detectWindowsCC 找 MinGW现在整个问题消失
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ() cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1") cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
// Auto-detect MinGW gcc on Windows
if cfg.goos == "windows" {
cc := detectWindowsCC()
if cc != "" {
cmd.Env = append(cmd.Env, "CC="+cc)
}
}
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// DEBUG: list files before building fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
entries, _ := os.ReadDir(".")
for _, e := range entries {
fmt.Printf(" [DEBUG] file: %s\n", e.Name())
}
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err) fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
return return
@ -465,8 +578,8 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
} }
type binEntry struct { type binEntry struct {
src string // 磁盘路径,如 build/plugin.so src string // 磁盘路径,如 build/plugin.bin
zip string // zip 中条目名,如 plugin.so zip string // zip 中条目名,如 plugin.bin.linux.amd64
} }
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。 // createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
@ -566,72 +679,8 @@ func toSnake(s string) string {
return strings.ToLower(strings.ReplaceAll(s, " ", "_")) return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
} }
// detectWindowsCC looks for a MinGW-w64 gcc on Windows for c-shared builds.
func detectWindowsCC() string {
// Check CC from environment first
if cc := os.Getenv("CC"); cc != "" {
if _, err := exec.LookPath(cc); err == nil {
return cc
}
}
// Check common MinGW install paths
candidates := []string{
"C:\\mingw64\\bin\\gcc.exe",
"C:\\MinGW\\bin\\gcc.exe",
"C:\\msys64\\mingw64\\bin\\gcc.exe",
"C:\\Users\\21989\\AppData\\Local\\Temp\\mingw64\\mingw64\\bin\\gcc.exe",
}
// Also search PATH for gcc
if path, err := exec.LookPath("gcc"); err == nil {
return path
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header, // stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
// since these can confuse cgo's type resolution. // since these can confuse cgo's type resolution.
// generateBridge generates the C ABI bridge files for non-Lua builds.
// Returns a cleanup function to remove generated files.
func generateBridge(goos string) func() {
const bridgeFile = "z_bridge_gen.go"
const cEntryFile = "z_entry.c"
os.Remove(bridgeFile)
os.Remove(cEntryFile)
var files []string
if goos == "windows" {
if err := os.WriteFile(bridgeFile, []byte(tmplBridge), 0644); err != nil {
fmt.Printf(" error: write bridge: %v\n", err)
return func() {}
}
files = append(files, bridgeFile)
} else {
if err := os.WriteFile(bridgeFile, []byte(tmplLinuxBridge), 0644); err != nil {
fmt.Printf(" error: write bridge: %v\n", err)
return func() {}
}
files = append(files, bridgeFile)
// Write C entry point file
if err := os.WriteFile(cEntryFile, []byte(tmplPluginInitC), 0644); err != nil {
fmt.Printf(" error: write C entry: %v\n", err)
return func() {}
}
files = append(files, cEntryFile)
}
return func() {
for _, f := range files {
os.Remove(f)
}
}
}
// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs // linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs
// for source files, generating auto-import stubs. Returns cleanup function. // for source files, generating auto-import stubs. Returns cleanup function.
func linkThirdpart(plg *PlgConfig, target string) func() { func linkThirdpart(plg *PlgConfig, target string) func() {

View File

@ -7,8 +7,6 @@ import (
"sort" "sort"
"strings" "strings"
"text/template" "text/template"
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
) )
func (p *PlgConfig) ReplacesToSlice() []string { func (p *PlgConfig) ReplacesToSlice() []string {
@ -62,25 +60,56 @@ type TemplateData struct {
SDKModule string SDKModule string
SDKVersion string SDKVersion string
// C ABI // SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
CABIVersion int //
CABIHeader string // 为何必须写gitcode 的模块不在 proxy.golang.org 上,只 require 一个
// 版本号的 go.mod 配上缺失的 go.sum新用户第一次 `plugindev build`
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
// 一个不存在的条目。有了本地 replacego 完全不需要 go.sum 条目。
SDKLocalPath string
} }
func cmdInit(args []string) { func cmdInit(args []string) {
if len(args) < 1 { if len(args) < 1 {
fmt.Println("Usage: plugindev init <name> [--lua]") fmt.Println("Usage: plugindev init <name> [--lua] [--type remotedevice]")
os.Exit(1) os.Exit(1)
} }
name := args[0] name := args[0]
isLua := false isLua := false
isRemoteDevice := false
for _, a := range args[1:] { for _, a := range args[1:] {
switch a { switch a {
case "--lua": case "--lua":
isLua = true isLua = true
case "--type", "-t":
// handled in next iteration
} }
} }
// also check --type remotedevice as a single arg
for i, a := range args[1:] {
if a == "--type" || a == "-t" {
if i+1 < len(args[1:]) {
if args[1:][i+1] == "remotedevice" {
isRemoteDevice = true
}
}
}
if a == "--type=remotedevice" || a == "-t=remotedevice" {
isRemoteDevice = true
}
}
if isRemoteDevice && isLua {
fmt.Println("error: --type remotedevice and --lua are mutually exclusive")
os.Exit(1)
}
// Remote device projects use different scaffold
if isRemoteDevice {
scaffoldRemoteDevice(name)
return
}
dir := name dir := name
if _, err := os.Stat(dir); !os.IsNotExist(err) { if _, err := os.Stat(dir); !os.IsNotExist(err) {
@ -88,7 +117,12 @@ func cmdInit(args []string) {
os.Exit(1) os.Exit(1)
} }
entry := "plugin.so" // Go 插件统一产出 plugin.binv1.0.0 子进程模式)。
//
// 此前这里写 "plugin.so"scaffold 出来的 plg.json 就带着一个已退场的
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
// build 实际不看这个值(只用它区分 Lua但模板不应误导。
entry := "plugin.bin"
var targets string var targets string
if isLua { if isLua {
entry = "main.lua" entry = "main.lua"
@ -112,20 +146,19 @@ func cmdInit(args []string) {
Tags: []string{name}, Tags: []string{name},
Targets: targets, Targets: targets,
}, },
IsLua: isLua, IsLua: isLua,
CABIVersion: meta.CABINum,
CABIHeader: tmplCABIHeader,
} }
// Detect SDK info for Go plugin go.mod. // Detect SDK info for Go plugin go.mod.
// 生成的 go.mod require SDK 线上模块版本,不写本地路径 replace // 生成的 go.mod require 外还写一条指向本机 SDK 的 replace
// 本地调试请用 `plugindev build --sdk-path <path>` 或手动加 replace // 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)
if !isLua { if !isLua {
sdkMod, goVer, _, sdkVer := detectSDKInfo() sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
data.ModulePath = name data.ModulePath = name
data.GoVersion = goVer data.GoVersion = goVer
data.SDKModule = sdkMod data.SDKModule = sdkMod
data.SDKVersion = "v" + sdkVer data.SDKVersion = "v" + sdkVer
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
} }
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
@ -192,6 +225,57 @@ func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
return modulePath, goVersion, root, sdkVersion return modulePath, goVersion, root, sdkVersion
} }
// scaffoldRemoteDevice 创建远程设备适配器项目脚手架
func scaffoldRemoteDevice(name string) {
dir := name
if _, err := os.Stat(dir); !os.IsNotExist(err) {
fmt.Printf("error: directory %q already exists\n", dir)
os.Exit(1)
}
nameEn := strings.Title(strings.ReplaceAll(name, "-", " "))
data := TemplateData{
Plg: PlgConfig{
Name: name,
NameZh: "中文名",
NameEn: nameEn,
Version: "0.1.0",
Description: name + " remote device adapter",
Author: "HomeAgent",
Entry: name,
Tags: []string{name, "remotedevice"},
},
}
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("error: create dir: %v\n", err)
os.Exit(1)
}
// 写入 main.c
writeTemplate(filepath.Join(dir, "main.c"), tmplRemoteDeviceMain, data)
// 写入 CMakeLists.txt
writeTemplate(filepath.Join(dir, "CMakeLists.txt"), tmplRemoteDeviceCMake, data)
// 创建 SDK 目录symlink/copy
sdkSrc := filepath.Join("..", "remotedevice")
sdkDst := filepath.Join(dir, "ha_remotedevice")
if _, err := os.Stat(sdkDst); os.IsNotExist(err) {
// 尝试创建符号链接,失败则提示
if err := os.Symlink(sdkSrc, sdkDst); err != nil {
fmt.Printf(" note: could not create symlink to SDK, copy manually:\n")
fmt.Printf(" cp -r %s %s\n", sdkSrc, sdkDst)
}
}
fmt.Printf("Created remote device adapter project %q\n", dir)
fmt.Printf(" cd %s && mkdir build && cd build && cmake .. && make\n", dir)
fmt.Printf(" Or include as subdirectory in your project:\n")
fmt.Printf(" add_subdirectory(%s)\n", dir)
}
func writeTemplate(path, content string, data TemplateData) { func writeTemplate(path, content string, data TemplateData) {
tmpl, err := template.New("").Parse(content) tmpl, err := template.New("").Parse(content)
if err != nil { if err != nil {

View File

@ -30,15 +30,20 @@ func help() {
fmt.Print(`HomeAgent Plugin Dev Tool fmt.Print(`HomeAgent Plugin Dev Tool
Usage: Usage:
plugindev init <name> Scaffold a new plugin project plugindev init <name> Scaffold a new plugin project
plugindev build [flags] Compile and package plugin plugindev init <name> --lua Create Lua plugin
plugindev clean Clean build/dist artifacts plugindev init <name> --type remotedevice
plugindev debug [dir] Interpret and debug plugin source Create C remote device adapter
plugindev sdk <command> Manage SDK versions plugindev build [flags] Compile and package plugin
plugindev clean Clean build/dist artifacts
plugindev debug [dir] Interpret and debug plugin source
plugindev sdk <command> Manage SDK versions
Flags: Flags:
--outdir Output directory (default: dist) --outdir Output directory (default: dist)
--target Target OS/arch (e.g. linux/amd64), repeatable --target Target OS/arch (e.g. linux/amd64), repeatable
--lua Create Lua plugin (for init) --lua Create Lua plugin (for init)
--type Project type: "remotedevice" (for init)
-t Alias for --type
`) `)
} }

View File

@ -0,0 +1,91 @@
package main
import (
"embed"
"fmt"
"os"
)
// 子进程插件运行时(外部插件多进程化)。
//
// 模板为何是**真实 .go 源文件** + //go:embed而不是 raw string
// 1100+ 行代码塞在字符串里写错只能等生成插件时才炸;作为源文件可被
// gofmt / go vet / go/parser 直接检查proc_runtime_test.go 的 16 项
// 静态检查就以此为前提)。
//
// 构建从 `-buildmode=c-shared` + CGO_ENABLED=1 变成普通 `go build` +
// CGO_ENABLED=0交叉编译不再需要目标平台的 C 工具链§3.1 连带消失项)。
//
// 设计依据docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3/6
//go:embed templates/proc_main.go.tmpl
//go:embed templates/proc_shm_unix.go.tmpl
//go:embed templates/proc_shm_windows.go.tmpl
var procTemplates embed.FS
// procRuntimeFiles 列出生成到插件目录的运行时文件。
//
// 共享段与事件通知的**传递机制**按平台不同Unix 继承 fd
// Windows 命名内核对象),故拆成带 build tag 的两个文件;
// 共享段**布局**与 RPC 逻辑完全平台无关,全在 proc_main 里。
//
// 这正是三套独立 ABI 实现收敛为单一 RPC 实现的效果:
// 平台差异从「整套 stage 下发/写回逻辑各写一份」缩到「三个挂载函数」。
var procRuntimeFiles = []struct {
tmpl string // 内嵌模板路径
out string // 生成到插件目录的文件名
}{
{"templates/proc_main.go.tmpl", "z_proc_gen.go"},
{"templates/proc_shm_unix.go.tmpl", "z_proc_shm_unix.go"},
{"templates/proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
}
// procEntryFile 是子进程插件的入口二进制名(与内核 internal/plugin/dynamic.go 的 binEntry 一致)。
//
// 全平台同名:进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名
// (对比 C ABI 时代的 .so/.dylib/.dll 三套产物 + 三套 ABI 实现)。
const procEntryFile = "plugin.bin"
// luaEntryFile 是 Lua 插件的入口。Lua 走解释器,不经过 Go 编译。
const luaEntryFile = "main.lua"
// procGenFile 是生成的主运行时文件名(兼容旧注释引用)。
// 前缀 z_ 使其在目录列表中排在业务代码之后。
const procGenFile = "z_proc_gen.go"
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
// 写入插件目录,返回清理函数。
func generateProcRuntime() (func(), error) {
// 清理历史 C ABI 产物:旧版 plugindev 生成过这两个文件,残留下来会与
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
os.Remove(stale)
}
var written []string
cleanup := func() {
for _, f := range written {
os.Remove(f)
}
}
for _, rf := range procRuntimeFiles {
data, err := procTemplates.ReadFile(rf.tmpl)
if err != nil {
cleanup()
return nil, fmt.Errorf("读取内嵌模板 %s: %w", rf.tmpl, err)
}
if err := os.WriteFile(rf.out, data, 0644); err != nil {
cleanup()
return nil, fmt.Errorf("写入 %s: %w", rf.out, err)
}
written = append(written, rf.out)
}
return cleanup, nil
}
// isProcEntry 已删除Go 插件一律产出 plugin.bin不再看 plg.json 的 entry 值。
//
// 为何忽略 entry17 个存量插件的 plg.json 都写着 "plugin.so"。若把 entry 当作
// 通道开关,迁移就得改 17 个文件——而「外部插件零改动」是本次迁移的硬约束。
// entry 现在只用于区分 Luamain.lua与 Go 插件。

View File

@ -0,0 +1,394 @@
package main
import (
"go/parser"
"go/token"
"os"
"regexp"
"strings"
"testing"
)
// 子进程运行时模板的静态检查Part 3
//
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
//
// 模板改为真实 .go 源文件(而非 raw string的直接收益就是这类检查可行。
func loadProcTemplate(t *testing.T) string {
t.Helper()
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
if err != nil {
t.Fatalf("读取内嵌模板: %v", err)
}
return string(data)
}
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
func stripComments(t *testing.T, src string) string {
t.Helper()
fs := token.NewFileSet()
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("解析模板: %v", err)
}
out := []byte(src)
for _, cg := range f.Comments {
s := fs.Position(cg.Pos()).Offset
e := fs.Position(cg.End()).Offset
for i := s; i < e && i < len(out); i++ {
if out[i] != '\n' {
out[i] = ' '
}
}
}
return string(out)
}
// 模板必须是合法 Go 源码。
func TestProcTemplate_ParsesAsGo(t *testing.T) {
src := loadProcTemplate(t)
fs := token.NewFileSet()
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
t.Fatalf("模板不是合法 Go 源码: %v", err)
}
}
// 模板必须提供 main(),且不得含 cgo 痕迹。
//
// 零 cgo 是迁移的核心收益之一§3.7 锁仲裁回内核后整个架构无 cgo
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "func main()") {
t.Error("子进程模板必须有 main() 入口")
}
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
code := stripComments(t, src)
for _, forbidden := range []string{
`import "C"`,
"//export ",
"C.CString",
"C.GoString",
"C.free",
} {
if strings.Contains(code, forbidden) {
t.Errorf("模板不应含 cgo 痕迹 %q零 cgo 是迁移的核心收益)", forbidden)
}
}
}
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
//
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
// 表现是插件调用返回「未知 method」测试能提前拦住。
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
src := loadProcTemplate(t)
// 51 个 C ABI method id 平移后的名字§3.2),加 stage 锁仲裁 2 个
required := []string{
// 注册面
"tool.register", "stage.register", "output.register", "api.register", "input.register",
// IO 注入
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
"io.setToolBlocks",
// 生命周期
"lifecycle.autoRestart",
// 图记忆
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
// 文档记忆
"doc.query", "doc.insert", "doc.remove", "doc.stats",
// 知识库
"knowledge.search", "knowledge.add", "knowledge.list",
// 文本记忆
"textmemory.append",
// 设置
"settings.get", "settings.set", "settings.registerDef",
"settings.getCore", "settings.setCore", "settings.listCore",
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
"settings.dataDir",
// LLM
"llm.listSources", "llm.setSource", "llm.currentSource",
// 社交图
"social.getPerson", "social.getNetwork", "social.getTrait",
"social.getRelations", "social.listPersons",
// 插件管理
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
// 共享段锁仲裁新增C ABI 下不存在此概念)
"stage.lock", "stage.unlock",
}
for _, m := range required {
if !strings.Contains(src, `"`+m+`"`) {
t.Errorf("模板缺少 core method %q内核已提供插件侧未接线", m)
}
}
}
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{
"handshake",
"plugin.init", "plugin.start", "plugin.stop",
"tool.invoke", "stage.invoke", "output.invoke",
} {
if !strings.Contains(src, `case "`+m+`"`) {
t.Errorf("模板未处理内核调用 %q", m)
}
}
}
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
//
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
// 而两边都不报错(同为 []byte
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
src := loadProcTemplate(t)
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
layout := map[string]string{
"shmOffMagic": "0",
"shmOffVersion": "4",
"shmOffArenaBase": "8",
"shmOffArenaCap": "12",
"shmOffArenaUsed": "16",
"shmOffCtxBase": "20",
"shmOffSeq": "24",
// 与内核 stageFieldCount / sliceSize 对齐
"shmStageFieldCount": "18",
"shmSliceSize": "8",
"shmVersion": "1",
}
constRe := func(name, want string) bool {
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
return re.MatchString(src)
}
for name, want := range layout {
if !constRe(name, want) {
t.Errorf("共享段常量 %s 应为 %s须与内核 internal/plugin/proc/shm.go 一致)", name, want)
}
}
// 字段枚举顺序:内核 stageField 的前若干项
fieldOrder := []string{
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
"fTokenUsage", "fErrors",
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
}
idx := -1
for _, f := range fieldOrder {
at := strings.Index(src, f)
if at < 0 {
t.Fatalf("模板缺少字段常量 %s", f)
}
if at <= idx {
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
}
idx = at
}
}
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
//
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{
"func handleStageInvoke(",
"stage.lock",
"readStageContext()",
"takeStageSnapshot(",
"writeStageDirty(",
"stage.unlock",
} {
if !strings.Contains(src, want) {
t.Errorf("stage 处理链路缺少 %q", want)
}
}
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
iRead := strings.Index(src, "readStageContext()")
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
if iLock < 0 || iRead < 0 || iWrite < 0 {
t.Fatal("stage 链路关键调用缺失")
}
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
iReadFn := strings.Index(stageFn, "readStageContext()")
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
}
}
// 快照必须存序列化字符串而非 Go 值。
//
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑StageContext 的切片字段与读出的值
// 共享底层内容handler 原地改元素sc.ToolResults[0].Result = x
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "strs map[int]string") ||
!strings.Contains(src, "jsons map[int]string") {
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
}
if !strings.Contains(src, "json.Marshal(v)") {
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
}
}
// arena 用尽必须显式报错不得静默截断§4.4 风险登记)。
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "arena 空间不足") {
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
}
}
// 日志必须走 stderrstdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
func TestProcTemplate_LogsToStderr(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
t.Error("日志必须走 stderr否则会破坏 stdout 的 RPC 帧")
}
}
// 请求必须在独立 goroutine 里处理。
//
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
// 就没人读应答帧 → 死锁。
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "go handleKernelRequest(&req)") {
t.Error("请求须在独立 goroutine 处理handler 内反向调用内核,同步处理会死锁)")
}
}
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
if !strings.Contains(src, want) {
t.Errorf("握手应校验并拒绝 %q", want)
}
}
}
// 全平台统一产出 plugin.bin。
//
// 这是三套独立 ABI 实现(.so/.dylib/.dll收敛为单一 RPC 实现的直接后果:
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
// Windows 走的是与 Linux 完全相同的 RPC 实现。
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
for _, target := range []string{
"linux/amd64", "linux/arm64",
"darwin/amd64", "darwin/arm64",
"windows/amd64",
"freebsd/amd64",
} {
cfg, errMsg := resolveBuild(target)
if cfg == nil {
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
}
if cfg.entryFile != procEntryFile {
t.Errorf("%s: 产物应为 %s实际 %s", target, procEntryFile, cfg.entryFile)
}
}
}
// lua 目标仍走解释器路径entry 字段唯一仍在使用的用途)。
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
for _, target := range []string{"lua", ""} {
cfg, kind := resolveBuild(target)
if cfg != nil {
t.Errorf("%q 应返回 nil cfgLua 不经 Go 编译)", target)
}
if kind != "lua" {
t.Errorf("%q 应识别为 lua实际 %q", target, kind)
}
}
}
// 不支持的平台明确报错,不静默产出错误产物。
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
cfg, errMsg := resolveBuild("plan9/amd64")
if cfg != nil {
t.Error("不支持的平台应返回 nil cfg")
}
if !strings.Contains(errMsg, "unsupported") {
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
}
}
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
seen := map[string]bool{}
for _, bt := range allBundleTargets {
if seen[bt.entry] {
t.Errorf("zip 条目名重复: %s会相互覆盖", bt.entry)
}
seen[bt.entry] = true
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
}
}
if len(allBundleTargets) == 0 {
t.Error("bundle 目标表不应为空")
}
}
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
func TestToolchain_NoCABIResiduals(t *testing.T) {
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := stripComments(t, string(data))
for _, forbidden := range []string{
"c-shared",
"CGO_ENABLED=1",
"detectWindowsCC",
"generateBridge",
"tmplLinuxBridge",
"tmplPluginInitC",
} {
if strings.Contains(src, forbidden) {
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
}
}
}
}
// Go 插件的构建不再读 plg.json 的 entry 值。
//
// 这是「外部插件零改动」的关键17 个存量插件的 plg.json 都写着 "plugin.so"
// 若把 entry 当通道开关,迁移就得改 17 个文件。
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
data, err := os.ReadFile("cmd_build.go")
if err != nil {
t.Fatalf("读 cmd_build.go: %v", err)
}
src := stripComments(t, string(data))
if strings.Contains(src, "isProcEntry") {
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin不看 entry 值")
}
// entry 仅剩 Lua 判定这一处用途
if !strings.Contains(src, "luaEntryFile") {
t.Error("IsLua 应改用 luaEntryFile 常量")
}
}

View File

@ -0,0 +1,247 @@
package main
import (
"encoding/json"
"testing"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 本测试验证 tmplLinuxBridge 中 snapshotWritable + changedFieldsOnly 的语义plan.md 11.3)。
// 模板字符串本身无法直接单测,这里以同一份逻辑复刻,防止回归。
// ❗ 模板与本文件须同步修改。
//
// 关键陷阱第一版实现踩过stageContextWritable 返回的切片字段与 sc 共享底层数组,
// handler 原地改元素时"before 快照"会跟着变diff 看不到变更 → 修复静默失效。
// 故 before 必须是**序列化后的字符串快照**。
func writable(sc *sdk.StageContext) map[string]interface{} {
m := map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"group_id": sc.GroupID,
"phase": string(sc.Phase),
"llm_text": sc.LLMText,
"final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
return m
}
// snapshot 对应模板里的 snapshotWritable逐字段序列化为不可变快照。
func snapshot(sc *sdk.StageContext) map[string]string {
snap := map[string]string{}
for k, v := range writable(sc) {
b, err := json.Marshal(v)
if err != nil {
continue
}
snap[k] = string(b)
}
return snap
}
// diffOnly 对应模板里的 changedFieldsOnly。
func diffOnly(before map[string]string, after map[string]interface{}) map[string]interface{} {
diff := map[string]interface{}{}
keys := map[string]bool{}
for k := range before {
keys[k] = true
}
for k := range after {
keys[k] = true
}
for k := range keys {
bRaw, bHas := before[k]
a, aHas := after[k]
switch {
case aHas && !bHas:
diff[k] = a
case aHas && bHas:
ab, _ := json.Marshal(a)
if bRaw != string(ab) {
diff[k] = a
}
case bHas && !aHas:
switch k {
case "tool_calls":
diff[k] = []sdk.ToolCall{}
case "tool_results":
diff[k] = []sdk.ToolResult{}
}
}
}
return diff
}
// 只读插件(如 weather 的 AfterToolcall不改任何字段 → 零回传。
// 这是修复 lost update 的关键:旧实现会回传它收到的旧快照,覆盖 sanitizer 的清洗结果。
func TestChangedFieldsOnly_ReadOnlyPluginReturnsNothing(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hello",
LLMText: "world",
ToolResults: []sdk.ToolResult{
{CallID: "c1", Name: "weather_query", Success: true, Result: "已清洗结果"},
},
}
before := snapshot(sc)
// 只读 handler读了但没改
_ = sc.ToolResults[0].Result
diff := diffOnly(before, writable(sc))
if len(diff) != 0 {
t.Fatalf("只读插件应零回传,实际回传 %d 个字段: %v", len(diff), diff)
}
}
// 改写插件(如 sanitizer 改 ToolResults→ 只回传被改的字段。
// ⚠️ 这里是原地改切片元素,正是共享底层数组陷阱的触发场景。
func TestChangedFieldsOnly_WriterReturnsOnlyChanged(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hello",
LLMText: "world",
ToolResults: []sdk.ToolResult{
{CallID: "c1", Name: "weather_query", Success: true, Result: "带\x1b[31mANSI\x1b[0m脏数据"},
},
}
before := snapshot(sc)
// sanitizer handler原地清洗 ToolResults
sc.ToolResults[0].Result = "带ANSI脏数据"
diff := diffOnly(before, writable(sc))
if len(diff) != 1 {
t.Fatalf("应只回传 tool_results 一个字段,实际 %d 个: %v", len(diff), diff)
}
if _, ok := diff["tool_results"]; !ok {
t.Fatalf("回传字段应为 tool_results实际 %v", diff)
}
// raw_message / llm_text 未改,不应出现(否则会覆盖其他插件的改写)
if _, ok := diff["raw_message"]; ok {
t.Error("raw_message 未改却被回传(会覆盖其他插件的改写)")
}
if _, ok := diff["llm_text"]; ok {
t.Error("llm_text 未改却被回传")
}
}
// 改写标量字段(如 before_output 改 FinalText→ 只回传该字段。
func TestChangedFieldsOnly_ScalarChange(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hi",
FinalText: " 带空白的回复 ",
LLMText: "原始",
}
before := snapshot(sc)
sc.FinalText = "带空白的回复"
diff := diffOnly(before, writable(sc))
if len(diff) != 1 || diff["final_text"] != "带空白的回复" {
t.Fatalf("应只回传 final_text实际 %v", diff)
}
}
// 首次设置 response短路→ 回传。
func TestChangedFieldsOnly_NewResponseIsReturned(t *testing.T) {
sc := &sdk.StageContext{RawMessage: "hi"}
before := snapshot(sc)
resp := "被插件短路"
sc.Response = &resp
diff := diffOnly(before, writable(sc))
if v, ok := diff["response"]; !ok || v != "被插件短路" {
t.Fatalf("新设置的 response 应回传,实际 %v", diff)
}
}
// 清空切片字段 → 显式回传空值让内核跟随。
func TestChangedFieldsOnly_ClearedSliceIsReturnedAsEmpty(t *testing.T) {
sc := &sdk.StageContext{
ToolCalls: []sdk.ToolCall{{ID: "t1", Name: "cmd_run"}},
}
before := snapshot(sc)
sc.ToolCalls = nil // 插件拒绝了全部工具调用
diff := diffOnly(before, writable(sc))
v, ok := diff["tool_calls"]
if !ok {
t.Fatalf("清空 tool_calls 应显式回传空值,实际 %v", diff)
}
if arr, _ := v.([]sdk.ToolCall); len(arr) != 0 {
t.Fatalf("应回传空切片,实际 %v", v)
}
}
// 复刻现网场景(实验 13sanitizer 清洗后 weather 只读回传,清洗结果不得被覆盖。
// 旧实现下 weather 会回传自己收到的旧快照(含脏数据),覆盖 sanitizer 的清洗(丢失率 1.6~4.3%)。
func TestChangedFieldsOnly_ProductionScenarioNoOverwrite(t *testing.T) {
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
clean := "天气:晴 28°C"
// 内核下发的原始快照(两插件各拿到一份副本)
kernelSnapshot := map[string]interface{}{
"raw_message": "查天气",
"llm_text": "",
"final_text": "",
"user_id": "u1",
"group_id": "",
"phase": "after_toolcall",
"no_memory": false,
"tool_results": []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
// sanitizer 副本:清洗
scSan := &sdk.StageContext{
RawMessage: "查天气",
UserID: "u1",
Phase: sdk.StageAfterToolcall,
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
beforeSan := snapshot(scSan)
scSan.ToolResults[0].Result = clean
diffSan := diffOnly(beforeSan, writable(scSan))
// weather 副本:只读,不改
scWea := &sdk.StageContext{
RawMessage: "查天气",
UserID: "u1",
Phase: sdk.StageAfterToolcall,
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
beforeWea := snapshot(scWea)
diffWea := diffOnly(beforeWea, writable(scWea))
// weather 必须零回传,否则它的旧快照会覆盖 sanitizer 的清洗
if len(diffWea) != 0 {
t.Fatalf("weather 只读却回传 %v —— 会覆盖 sanitizer 清洗结果", diffWea)
}
// sanitizer 必须回传 tool_results
if _, ok := diffSan["tool_results"]; !ok {
t.Fatalf("sanitizer 改写了 tool_results 却未回传:%v", diffSan)
}
// 内核按 sanitizer → weather 顺序应用 diffweather 后到,是最坏情形)
kernel := map[string]interface{}{}
for k, v := range kernelSnapshot {
kernel[k] = v
}
for k, v := range diffSan {
kernel[k] = v
}
for k, v := range diffWea {
kernel[k] = v
}
res, _ := kernel["tool_results"].([]sdk.ToolResult)
if len(res) == 0 || res[0].Result != clean {
t.Fatalf("清洗结果被覆盖:期望 %q实际 %v", clean, kernel["tool_results"])
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
//go:build linux || darwin || freebsd
package main
import (
"fmt"
"os"
"syscall"
)
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
//
// fd 布局(与内核 internal/plugin/proc/plugin.go 的 ExtraFiles 顺序一致):
//
// fd 3 = StageContext 段memfd / 已 unlink 的临时文件)
// fd 4 = 事件环段
// fd 5 = 事件通知Linux eventfd / macOS pipe 读端)
//
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
const (
fdStageShm = 3
fdEvtRingShm = 4
fdEvtNotifier = 5
)
// attachStageShm 挂载 StageContext 共享段。
//
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
func attachStageShm(size int) ([]byte, error) {
return syscall.Mmap(fdStageShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}
// attachEvtRingShm 挂载事件环段。
func attachEvtRingShm(size int) ([]byte, error) {
return syscall.Mmap(fdEvtRingShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}
// openEvtNotifier 打开事件通知读端。
func openEvtNotifier() (evtWaiter, error) {
f := os.NewFile(fdEvtNotifier, "evtnotify")
if f == nil {
return nil, fmt.Errorf("fd %d 不是有效的通知句柄", fdEvtNotifier)
}
return &unixEvtWaiter{f: f}, nil
}
// unixEvtWaiter 用 eventfd/pipe 的阻塞 Read 等待通知。
//
// os.NewFile 把 fd 注册进 runtime netpollerRead 阻塞时只 park goroutine
// 不占 OS 线程(实验 1200 个等待者仅增 1 个 OS 线程)。
// 反面对照是经 cgo 调 sem_wait——那会阻塞整个 M。
type unixEvtWaiter struct {
f *os.File
}
func (w *unixEvtWaiter) Wait(buf []byte) error {
_, err := w.f.Read(buf)
return err
}

View File

@ -0,0 +1,153 @@
//go:build windows
package main
import (
"fmt"
"os"
"syscall"
"unsafe"
)
// Windows 侧共享段挂载:走命名对象而非继承 fd。
//
// 为何不能照抄 UnixWindows 没有 fd 继承语义,`ExtraFiles` 在 os/exec 的
// Windows 实现里不被支持。等价机制是命名内核对象——父进程用
// CreateFileMapping / CreateEvent 建带名字的对象,子进程按同名 Open 拿到同一对象。
//
// 名字经环境变量传入(内核 internal/plugin/proc/plugin_windows.go 设置),
// 而不是硬编码:多个 homed 实例并存时不能撞名。
//
// **这是 §9.2 的正解**C ABI 时代 Windows 是第三套独立 ABI 实现,
// stage 只下发 3 个字段且完全没有写回sanitizer 这类改写型插件静默失效。
// 现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局,
// 差异被收敛到本文件的三个函数里。
const (
envStageShmName = "HOMEAGENT_SHM_STAGE"
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
envEvtEventName = "HOMEAGENT_EVT_EVENT"
)
// Windows API 绑定:用 LazyDLL 而非 golang.org/x/sys/windows。
//
// 原因OpenFileMappingW / OpenEventW 未被标准库 syscall 包导出。
// 引入 x/sys 会给**每个插件的 go.mod 加一个新依赖**
// 而「外部插件零改动」是本次迁移的硬约束(插件仅依赖公开 SDK
// LazyDLL 属于标准库 syscall零新增依赖。
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenFileMappingW = kernel32.NewProc("OpenFileMappingW")
procOpenEventW = kernel32.NewProc("OpenEventW")
)
const (
winEventModifyState = 0x0002
winSynchronize = 0x00100000
)
// openFileMappingW 封装 OpenFileMappingW。
func openFileMappingW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
var inheritFlag uintptr
if inherit {
inheritFlag = 1
}
r, _, err := procOpenFileMappingW.Call(
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
if r == 0 {
return 0, err
}
return syscall.Handle(r), nil
}
// openEventW 封装 OpenEventW。
func openEventW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
var inheritFlag uintptr
if inherit {
inheritFlag = 1
}
r, _, err := procOpenEventW.Call(
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
if r == 0 {
return 0, err
}
return syscall.Handle(r), nil
}
// attachStageShm 按名字打开 StageContext 段并映射。
func attachStageShm(size int) ([]byte, error) {
return openNamedMapping(os.Getenv(envStageShmName), size, "StageContext 段")
}
// attachEvtRingShm 按名字打开事件环段并映射。
func attachEvtRingShm(size int) ([]byte, error) {
return openNamedMapping(os.Getenv(envEvtRingName), size, "事件环段")
}
// openNamedMapping 打开命名共享段并映射为 []byte。
//
// 与 Unix 的 mmap 语义对齐MapViewOfFile 返回的地址在本进程虚拟空间,
// 段内偏移仍是相对的,故跨进程解引用正确。
func openNamedMapping(name string, size int, what string) ([]byte, error) {
if name == "" {
return nil, fmt.Errorf("%s 名字未经环境变量传入", what)
}
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, fmt.Errorf("%s 名字非法: %w", what, err)
}
h, err := openFileMappingW(syscall.FILE_MAP_WRITE, false, namePtr)
if err != nil {
return nil, fmt.Errorf("打开 %s%s: %w", what, name, err)
}
addr, err := syscall.MapViewOfFile(h, syscall.FILE_MAP_WRITE, 0, 0, uintptr(size))
if err != nil {
syscall.CloseHandle(h)
return nil, fmt.Errorf("映射 %s: %w", what, err)
}
// 句柄不关:视图存活期间必须保持句柄有效,进程退出时由 OS 回收。
return unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), nil
}
// openEvtNotifier 按名字打开事件通知对象。
func openEvtNotifier() (evtWaiter, error) {
name := os.Getenv(envEvtEventName)
if name == "" {
return nil, fmt.Errorf("事件通知对象名字未经环境变量传入")
}
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, fmt.Errorf("事件对象名字非法: %w", err)
}
h, err := openEventW(winSynchronize|winEventModifyState, false, namePtr)
if err != nil {
return nil, fmt.Errorf("打开事件对象(%s: %w", name, err)
}
return &windowsEvtWaiter{h: h}, nil
}
// windowsEvtWaiter 用命名 Event 对象等待通知。
//
// 与 eventfd 的差异Event 是二元信号而非计数器,多次 SetEvent 只对应
// 一次唤醒。这不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq
// 批量 drain一次唤醒能处理累积的全部事件。
//
// WaitForSingleObject 阻塞的是 OS 线程而非仅 goroutine故不如 eventfd
// 的 netpoller 路径省线程。每插件一个消费 goroutine17 插件即 17 线程,
// 在可接受范围(实验 5 实测 17 子进程共 84 线程)。
type windowsEvtWaiter struct {
h syscall.Handle
}
func (w *windowsEvtWaiter) Wait(buf []byte) error {
ev, err := syscall.WaitForSingleObject(w.h, syscall.INFINITE)
if err != nil {
return err
}
if ev != syscall.WAIT_OBJECT_0 {
return fmt.Errorf("等待事件对象返回 0x%x", ev)
}
return nil
}