mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 17:08:01 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e256023399 | |||
| 092d8f4ab0 | |||
| 5ed8d65479 | |||
| 9f844123fe | |||
| ef0e58ee23 | |||
| 09b64dcb53 | |||
| 56485194df | |||
| 61f307be1a | |||
| 59c6e1844c | |||
| 5c1574be25 | |||
| 68497b4092 | |||
| 130f805b6e | |||
| cd1984e26e | |||
| 6184736fd4 | |||
| 81bfdfce1d | |||
| e3f93e254b | |||
| d57c5eaf3e | |||
| 16b4a56ee8 | |||
| 2e6d037bb9 | |||
| cf77bf389e | |||
| fc876c5554 | |||
| 5c5df9cfb9 | |||
| c91739d670 | |||
| 6527a40539 | |||
| 392f391f68 | |||
| cca9fdce9c | |||
| b6e30f9279 | |||
| 8e5610c494 | |||
| 1796395668 | |||
| f3d87ec35f | |||
| aee63a4f98 | |||
| fb07081929 | |||
| db5d3133ea | |||
| 12a8e99892 | |||
| 62447e3952 | |||
| bc1a005885 | |||
| 2b54814037 | |||
| 429fe9e1b9 |
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
# Build artifacts
|
||||
*.so
|
||||
*.dll
|
||||
*.o
|
||||
*.exe
|
||||
*.hmap
|
||||
plugin.json
|
||||
|
||||
@ -8,6 +10,11 @@ plugin.json
|
||||
build/
|
||||
dist/
|
||||
|
||||
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
|
||||
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB),每次重编都在历史里
|
||||
# 再叠一份,而它们本质是可从源码复现的产物。
|
||||
bin/
|
||||
|
||||
# Test artifacts
|
||||
testdist/
|
||||
|
||||
@ -25,3 +32,6 @@ z_entry.c
|
||||
# Pre-built plugindev binaries in bin/ should be tracked
|
||||
!bin/plugindev*
|
||||
!bin/*.exe
|
||||
|
||||
# plugindev binary in tools/
|
||||
tools/plugindev/plugindev
|
||||
|
||||
410
README.md
410
README.md
@ -142,7 +142,20 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
|
||||
## plugindev 工具链
|
||||
|
||||
`plugindev` 提供插件开发全流程支持:
|
||||
`plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
||||
|
||||
```bash
|
||||
# 从 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
|
||||
|
||||
# 或从源码自己编
|
||||
cd tools/plugindev && go build -o plugindev .
|
||||
```
|
||||
|
||||
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
|
||||
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
@ -175,7 +188,7 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
"version": "1.0.0",
|
||||
"description": "天气查询插件",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"entry": "plugin.bin",
|
||||
"tags": ["weather", "forecast"],
|
||||
"targets": "linux/amd64,windows/amd64",
|
||||
"outdir": "dist",
|
||||
@ -197,7 +210,7 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
| `version` | string | 版本号 |
|
||||
| `description` | 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[] | 标签 |
|
||||
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) |
|
||||
| `outdir` | string | 输出目录(默认 `dist`) |
|
||||
@ -212,11 +225,14 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
`.hmap` 为 ZIP 归档,包含:
|
||||
|
||||
- `plugin.json` — 插件元数据
|
||||
- `plugin.so` — Go 编译产物(Linux)
|
||||
- `plugin.dll` — Go 编译产物(Windows)
|
||||
- `plugin.dylib` — Go 编译产物(macOS,bundle 模式)
|
||||
- `plugin.bin` — Go 编译产物(单平台构建)
|
||||
- `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
|
||||
安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
|
||||
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||
|
||||
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
|
||||
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
|
||||
|
||||
## 插件生命周期
|
||||
|
||||
### 入口函数
|
||||
@ -246,6 +262,21 @@ return plugin
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
||||
- `Stop() error` — 插件停止,释放资源
|
||||
- `sdk.RegisterStopHandler(fn func())` — 注册停止清理回调。内核(内置插件)或 z_bridge(外部插件)会在调用插件 `Stop()` **之前**统一执行已注册的 handler(后注册先执行,执行后清空、幂等)。适合做持久化落盘、取消后台任务等清理:此时插件内存状态仍然新鲜,避免在 `Stop()` 阶段以陈旧状态写回导致数据复活。
|
||||
|
||||
### 删除清理(onRemove)
|
||||
|
||||
`Stop`/`RegisterStopHandler` 在插件**停止**(含重载、禁用)时执行;`RegisterOnRemoveHandler` 仅在插件被**卸载(删除)**时执行一次,重载/禁用不触发:
|
||||
|
||||
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
||||
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
||||
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
|
||||
|
||||
```go
|
||||
sdk.RegisterOnRemoveHandler(func() {
|
||||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||
})
|
||||
```
|
||||
|
||||
### 自动重启
|
||||
|
||||
@ -270,22 +301,341 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
## 示例插件
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| a2a | Agent-to-Agent 协议通信 |
|
||||
| ai_image | AI 图片生成 |
|
||||
| bili | Bilibili 视频下载 |
|
||||
| browser | 网络搜索、网页抓取、浏览器渲染 |
|
||||
| calendar | 日历管理 |
|
||||
| editdoc | 文档编辑 |
|
||||
| files | 文件管理 |
|
||||
| memo | 备忘录 |
|
||||
| music | 音乐播放 |
|
||||
| ocr | 光学字符识别 |
|
||||
| qq | QQ 消息集成(NapCat webhook,15 个工具) |
|
||||
| rss | RSS 订阅 |
|
||||
| sanitizer | 内容清洗/安全过滤 |
|
||||
| weather | 天气查询(wttr.in) |
|
||||
| 插件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| [weather](example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||
| [luademo](example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||
| [qq](example/qq) | Go | QQ 消息集成(NapCat),17 个工具,输入/输出通道完整对接 |
|
||||
| [a2a](example/a2a) | Go | Agent-to-Agent 协议通信 |
|
||||
| [ai_image](example/ai_image) | Go | AI 图片生成 |
|
||||
| [bili](example/bili) | Go | Bilibili 视频下载 |
|
||||
| [browser](example/browser) | Go | 网络搜索、网页抓取、浏览器渲染 |
|
||||
| [calendar](example/calendar) | Go | 日历管理 |
|
||||
| [editdoc](example/editdoc) | Go | 文档编辑 |
|
||||
| [files](example/files) | Go | 文件管理 |
|
||||
| [memo](example/memo) | Go | 备忘录(PreAction 注入 + 定时提醒) |
|
||||
| [music](example/music) | Go | 音乐播放 |
|
||||
| [ocr](example/ocr) | Go | 光学字符识别 |
|
||||
| [rss](example/rss) | Go | RSS 订阅 |
|
||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||
|
||||
## Remote Device SDK
|
||||
|
||||
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ha_remotedevice (C SDK) │
|
||||
│ 协议引擎 │ WS 帧 │ JSON │ 状态机 │ 传输抽象 │
|
||||
└──────────┬──────────────────────────────────────┘
|
||||
│ 同一份 C 代码,设备端和 App 端共用
|
||||
┌──────┴──────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────────────────┐
|
||||
│ ESP32 裸机 │ │ Linux 设备上的 App │
|
||||
│ 纯 C 直调 │ │ (Python ctypes / Go CGo / │
|
||||
│ 简单命令处理 │ │ Node addon / C# P/Invoke) │
|
||||
└──────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### 声明式 API 设计
|
||||
|
||||
设备在代码中声明**自己是什么**、**能做什么**、**支持哪些命令**,每个命令对应独立处理函数,SDK 自动分发并回执结果:
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* 声明能力 */
|
||||
const char *caps[] = {"camera", "status", NULL};
|
||||
|
||||
/* 声明式命令处理表:每个命令绑定独立处理函数 */
|
||||
static ha_status_t handle_camerasue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = args[0] ? atoi(args) : 0;
|
||||
// 拍照/录像...
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,..."; // SDK 自动回执
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camerasue},
|
||||
{.command = "screensee", .handler = handle_screensee},
|
||||
{.command = "speakeruse", .handler = handle_speakeruse},
|
||||
{.command = NULL}, /* 标记结束 */
|
||||
};
|
||||
|
||||
ha_config_t config = {
|
||||
.transport = my_transport, // 用户实现 4 个函数
|
||||
.server = "192.168.1.100:9890",
|
||||
.token = "my-token",
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "门口摄像头",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
},
|
||||
.handlers = handlers, // 声明式命令处理表
|
||||
.on_state = my_state_handler,
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_client_start(client);
|
||||
while (1) {
|
||||
ha_client_process(client); // 主循环处理
|
||||
}
|
||||
```
|
||||
|
||||
### 传输层抽象
|
||||
|
||||
用户只需实现 4 个函数,适配不同平台:
|
||||
|
||||
```c
|
||||
ha_transport_t my_transport = {
|
||||
.connect = my_tcp_connect, // 建立 TCP 连接
|
||||
.send = my_tcp_send, // 发送数据
|
||||
.recv = my_tcp_recv, // 接收数据(阻塞)
|
||||
.close = my_tcp_close, // 关闭连接
|
||||
.ctx = &my_platform_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
### 支持的协议
|
||||
|
||||
| 功能 | API |
|
||||
|------|-----|
|
||||
| WS 连接 + 握手 | `ha_client_start` 自动完成 |
|
||||
| 设备注册 (hello/bind) | 启动时自动发送 |
|
||||
| 命令接收 (shell/homeagent) | `handlers` 表声明式注册,SDK 自动分发 |
|
||||
| 命令回执 | `ha_client_send_result` |
|
||||
| 二进制分块(录像等) | `ha_client_send_data_chunked` |
|
||||
| TTS 音频接收 | `on_binary` 回调 |
|
||||
| 事件上报 | `ha_client_send_event` |
|
||||
| 状态上报 | `ha_client_send_status` |
|
||||
| 心跳保持 | 自动 ping/pong |
|
||||
|
||||
### 使用方式
|
||||
|
||||
通过 `plugindev` 工具链初始化项目:
|
||||
|
||||
```bash
|
||||
plugindev init my-adapter --type remotedevice
|
||||
```
|
||||
|
||||
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(path/to/ha_remotedevice)
|
||||
target_link_libraries(my_app ha_remotedevice)
|
||||
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||
```
|
||||
|
||||
### 快速接入指南
|
||||
|
||||
以下是从零到设备成功接入 HomeAgent 的完整步骤。
|
||||
|
||||
#### 1. 准备工作
|
||||
|
||||
在 HomeAgent 平台上创建接入令牌:
|
||||
|
||||
```bash
|
||||
# 在 HomeAgent 服务端创建一个设备接入令牌
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"esp32-cam-1","name":"门口摄像头","kind":"camera"}'
|
||||
# 返回: {"token":"ha-dev-token-xxxxx"}
|
||||
```
|
||||
|
||||
记录下返回的 `token`,设备端配置时使用。
|
||||
|
||||
#### 2. 实现传输层(4 个函数)
|
||||
|
||||
根据你的平台实现 `ha_transport_t` 的 4 个函数指针。以下是几种常见场景:
|
||||
|
||||
**场景 A:带 TCP/IP 栈的嵌入式设备(如 ESP32 + lwIP)**
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||||
struct sockaddr_in addr;
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) return -1;
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||||
if (ret < 0) { closesocket(sock); return -1; }
|
||||
*(int *)ctx = sock;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return send(sock, (const char *)data, len, 0);
|
||||
}
|
||||
|
||||
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return recv(sock, (char *)buf, len, 0);
|
||||
}
|
||||
|
||||
static void esp_close(void *ctx) {
|
||||
int sock = *(int *)ctx;
|
||||
closesocket(sock);
|
||||
}
|
||||
|
||||
int esp_ctx = -1;
|
||||
ha_transport_t transport = {
|
||||
.connect = esp_connect,
|
||||
.send = esp_send,
|
||||
.recv = esp_recv,
|
||||
.close = esp_close,
|
||||
.ctx = &esp_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
**场景 B:通过串口(UART)连接透传模块**
|
||||
|
||||
```c
|
||||
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||||
(void)host; (void)port;
|
||||
// 初始化 UART,波特率 115200
|
||||
return uart_init((uart_ctx_t *)ctx, 115200);
|
||||
}
|
||||
|
||||
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||||
return uart_write((uart_ctx_t *)ctx, data, len);
|
||||
}
|
||||
|
||||
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||||
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||||
}
|
||||
|
||||
static void uart_close(void *ctx) {
|
||||
uart_deinit((uart_ctx_t *)ctx);
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:UART 透传时,另一端需运行一个 TCP 桥接程序,将串口数据转发到 HomeAgent 的 WebSocket 端口。
|
||||
|
||||
#### 3. 声明设备能力和命令处理
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* 声明设备能力 */
|
||||
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||||
|
||||
/* 处理 camerasue 命令(拍照) */
|
||||
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = args[0] ? atoi(args) : 0; // 参数:录像时长
|
||||
|
||||
// 拍照或录像,将结果填入 result
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 图像数据
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 处理 shell 命令 */
|
||||
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
// 执行 shell 命令,args 为完整命令字符串
|
||||
result->status = 0;
|
||||
result->output = "command executed";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 声明式命令处理表 */
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camera},
|
||||
{.command = "screensee", .handler = handle_camera},
|
||||
{.command = "speakeruse", .handler = handle_speaker},
|
||||
{.command = NULL}, /* 标记结束 */
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 配置并启动客户端
|
||||
|
||||
```c
|
||||
ha_config_t config = {
|
||||
.transport = transport, // 传输层实现
|
||||
.server = "192.168.1.100:9890", // HomeAgent 服务端地址
|
||||
.token = "ha-dev-token-xxxxx", // 第 1 步获取的令牌
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "门口摄像头",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||||
},
|
||||
.handlers = handlers, // 命令处理表
|
||||
.on_binary = on_binary_data, // 接收 TTS 音频等二进制数据
|
||||
.on_state = on_state_change, // 连接状态变化回调
|
||||
.ping_interval = 30, // 心跳间隔秒数
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_status_t ret = ha_client_start(client);
|
||||
if (ret != HA_OK) {
|
||||
printf("设备接入失败: %d\n", ret);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 主循环 */
|
||||
while (1) {
|
||||
ha_client_process(client); // 处理协议帧、心跳、命令分发
|
||||
|
||||
/* 可选:设备主动上报事件 */
|
||||
ha_client_send_event(client, "motion_detected",
|
||||
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||||
|
||||
/* 可选:上报设备状态 */
|
||||
ha_client_send_status(client, "online");
|
||||
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS); // 嵌入式 RTOS 风格延时
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 验证连接
|
||||
|
||||
在 HomeAgent 服务端检查设备是否在线:
|
||||
|
||||
```bash
|
||||
# 查看已注册设备列表
|
||||
curl http://<homeagent-server>:8080/api/v1/device/list
|
||||
# 预期输出包含: {"device_id":"esp32-cam-1","status":"online",...}
|
||||
|
||||
# 向设备发送命令(测试 camerasue)
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cmd":"camerasue","args":"3"}'
|
||||
# 预期返回: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||||
```
|
||||
|
||||
#### 6. 调试技巧
|
||||
|
||||
| 问题 | 检查点 |
|
||||
|------|--------|
|
||||
| 连接失败 | 确认 `server` 地址和端口可通;检查 `token` 是否正确 |
|
||||
| WS 握手失败 | 确认 HomeAgent 服务端已开启 WebSocket 支持 |
|
||||
| 命令无响应 | 确认 `handlers` 表中注册了对应命令名;检查 `on_binary` 是否配置 |
|
||||
| 断线重连 | `max_reconnect` 控制重连次数,-1 为无限重连 |
|
||||
| 内存不足(嵌入式) | 定义 `HA_NO_ALLOC` 宏禁用动态内存分配 |
|
||||
|
||||
### 位置
|
||||
|
||||
- **SDK 源码**: `remotedevice/`
|
||||
- **plugindev 模板**: `plugindev init --type remotedevice`
|
||||
|
||||
## 构建与安装
|
||||
|
||||
@ -295,15 +645,21 @@ enabled := sdk.AutoRestart()
|
||||
plugindev build
|
||||
```
|
||||
|
||||
输出 `.hmap` 包到项目目录。
|
||||
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`)。
|
||||
|
||||
### 安装
|
||||
|
||||
通过 pluginmgr HTTP API 安装:
|
||||
通过 pluginmgr HTTP API 安装(端口默认 9876,仅监听 127.0.0.1,无鉴权):
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
# 本地路径
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||||
|
||||
# 直接上传二进制
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
--data-binary @dist/my-plugin.hmap
|
||||
```
|
||||
|
||||
或手动将 `.hmap` 放入插件目录后重启平台。
|
||||
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
||||
|
||||
398
README_EN.md
398
README_EN.md
@ -142,14 +142,30 @@ Plugin developers only need to implement the `Plugin` interface and export a `Ne
|
||||
|
||||
## 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 |
|
||||
|---------|-------------|
|
||||
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
|
||||
| `plugindev build` | Build plugin, output .hmap package |
|
||||
| `plugindev clean` | Clean build artifacts |
|
||||
| `plugindev debug` | Run plugin in local debug mode |
|
||||
| `plugindev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
||||
| `plugindev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
||||
| `plugindev clean` | Clean `build/` and `dist/` plus generated files |
|
||||
| `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.
|
||||
|
||||
@ -163,7 +179,7 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
"version": "1.0.0",
|
||||
"description": "Weather plugin",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"entry": "plugin.bin",
|
||||
"tags": ["weather", "forecast"],
|
||||
"targets": "linux/amd64,windows/amd64",
|
||||
"outdir": "dist",
|
||||
@ -185,7 +201,7 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
| `version` | string | Version |
|
||||
| `description` | string | Plugin description |
|
||||
| `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 |
|
||||
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
||||
| `outdir` | string | Output directory (default `dist`) |
|
||||
@ -198,16 +214,36 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
`.hmap` is a ZIP archive containing:
|
||||
|
||||
- `plugin.json` — plugin metadata
|
||||
- `plugin.so` — Go compiled artifact (Linux)
|
||||
- `plugin.dll` — Go compiled artifact (Windows)
|
||||
- `plugin.bin` — Go compiled artifact (single-platform build)
|
||||
- `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)
|
||||
|
||||
> 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
|
||||
|
||||
### Start & Stop
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||||
- `Stop() error` — Plugin shutdown, release resources
|
||||
- `sdk.RegisterStopHandler(fn func())` — Register a shutdown cleanup callback. The kernel (for built-in plugins) or z_bridge (for external plugins) runs all registered handlers **before** calling the plugin's `Stop()` (LIFO order, cleared after running — idempotent). Use it for persistence and cancelling background work: plugin memory is still fresh at that point, avoiding stale-state write-backs that resurrect deleted data.
|
||||
|
||||
### Remove Cleanup (onRemove)
|
||||
|
||||
`Stop` / `RegisterStopHandler` run whenever the plugin **stops** (including reload and disable); `RegisterOnRemoveHandler` runs **only once when the plugin is uninstalled (removed)** — never on reload or disable:
|
||||
|
||||
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
|
||||
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
|
||||
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
|
||||
|
||||
```go
|
||||
sdk.RegisterOnRemoveHandler(func() {
|
||||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||
})
|
||||
```
|
||||
|
||||
### Auto-Restart
|
||||
|
||||
@ -232,18 +268,322 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
||||
|
||||
## Example Plugins
|
||||
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| a2a | Agent-to-Agent protocol communication |
|
||||
| bili | Bilibili data fetching |
|
||||
| editdoc | Document editing |
|
||||
| files | File management |
|
||||
| memo | Memo/notes |
|
||||
| ocr | Optical character recognition |
|
||||
| qq | QQ messaging integration |
|
||||
| sanitizer | Content sanitization/safety filtering |
|
||||
| web | Web browsing and interaction |
|
||||
| webfetch | Web content fetching |
|
||||
| Plugin | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||
| [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||
| [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
|
||||
| [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
|
||||
| [ai_image](example/ai_image) | Go | AI image generation |
|
||||
| [bili](example/bili) | Go | Bilibili video downloading |
|
||||
| [browser](example/browser) | Go | Web search, page fetching, browser rendering |
|
||||
| [calendar](example/calendar) | Go | Calendar management |
|
||||
| [editdoc](example/editdoc) | Go | Document editing |
|
||||
| [files](example/files) | Go | File management |
|
||||
| [memo](example/memo) | Go | Memos (PreAction injection + scheduled reminders) |
|
||||
| [music](example/music) | Go | Music playback |
|
||||
| [ocr](example/ocr) | Go | Optical character recognition |
|
||||
| [rss](example/rss) | Go | RSS subscriptions |
|
||||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
||||
|
||||
## Remote Device SDK
|
||||
|
||||
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ha_remotedevice (C SDK) │
|
||||
│ Protocol Engine │ WS Frames │ JSON │ State │
|
||||
│ Machine │ Transport Abstraction │
|
||||
└──────────┬──────────────────────────────────────┘
|
||||
│ Same C code, shared by device & app
|
||||
┌──────┴──────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────────────────┐
|
||||
│ ESP32 Bare │ │ Linux App │
|
||||
│ Pure C │ │ (Python ctypes / Go CGo /│
|
||||
│ Simple Cmd │ │ Node addon / C# P/Invoke)│
|
||||
└──────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### Declarative API Design
|
||||
|
||||
The device declares **what it is** and **what it can do** in code. The SDK handles all protocol details automatically:
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* Declare capabilities */
|
||||
const char *caps[] = {"camera", "status", NULL};
|
||||
|
||||
ha_config_t config = {
|
||||
.transport = my_transport, // User implements 4 functions
|
||||
.server = "192.168.1.100:9890",
|
||||
.token = "my-token",
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "Front Door Camera",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
},
|
||||
.on_cmd = my_cmd_handler, // Called when receiving commands
|
||||
.on_binary = my_data_handler, // Called on binary data (TTS audio, etc.)
|
||||
.on_state = my_state_handler, // Connection state changes
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_client_start(client);
|
||||
while (1) {
|
||||
ha_client_process(client); // Main loop processing
|
||||
}
|
||||
```
|
||||
|
||||
### Transport Layer Abstraction
|
||||
|
||||
Users only need to implement 4 functions to adapt to different platforms:
|
||||
|
||||
```c
|
||||
ha_transport_t my_transport = {
|
||||
.connect = my_tcp_connect, // Establish TCP connection
|
||||
.send = my_tcp_send, // Send data
|
||||
.recv = my_tcp_recv, // Receive data (blocking)
|
||||
.close = my_tcp_close, // Close connection
|
||||
.ctx = &my_platform_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
### Protocol Support
|
||||
|
||||
| Feature | API |
|
||||
|---------|-----|
|
||||
| WS connection + handshake | Automatic via `ha_client_start` |
|
||||
| Device registration (hello/bind) | Automatic on startup |
|
||||
| Command receive (shell/homeagent) | `on_cmd` callback |
|
||||
| Command result | `ha_client_send_result` |
|
||||
| Binary chunked transfer (video) | `ha_client_send_data_chunked` |
|
||||
| TTS audio receive | `on_binary` callback |
|
||||
| Event reporting | `ha_client_send_event` |
|
||||
| Status reporting | `ha_client_send_status` |
|
||||
| Heartbeat keepalive | Automatic ping/pong |
|
||||
|
||||
### Usage
|
||||
|
||||
Initialize a project via the `plugindev` toolchain:
|
||||
|
||||
```bash
|
||||
plugindev init my-adapter --type remotedevice
|
||||
```
|
||||
|
||||
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(path/to/ha_remotedevice)
|
||||
target_link_libraries(my_app ha_remotedevice)
|
||||
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||
```
|
||||
|
||||
### Quick Start Guide
|
||||
|
||||
A complete step-by-step guide from zero to a device successfully connected to HomeAgent.
|
||||
|
||||
#### Step 1: Preparation
|
||||
|
||||
Create an access token on the HomeAgent platform:
|
||||
|
||||
```bash
|
||||
# Create a device access token on the HomeAgent server
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"esp32-cam-1","name":"Front Door Camera","kind":"camera"}'
|
||||
# Returns: {"token":"ha-dev-token-xxxxx"}
|
||||
```
|
||||
|
||||
Save the returned `token` — you'll need it in the device configuration.
|
||||
|
||||
#### Step 2: Implement the Transport Layer (4 functions)
|
||||
|
||||
Implement the 4 function pointers of `ha_transport_t` for your platform. Here are common scenarios:
|
||||
|
||||
**Scenario A: Embedded device with TCP/IP stack (e.g., ESP32 + lwIP)**
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||||
struct sockaddr_in addr;
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) return -1;
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||||
if (ret < 0) { closesocket(sock); return -1; }
|
||||
*(int *)ctx = sock;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return send(sock, (const char *)data, len, 0);
|
||||
}
|
||||
|
||||
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||||
int sock = *(int *)ctx;
|
||||
return recv(sock, (char *)buf, len, 0);
|
||||
}
|
||||
|
||||
static void esp_close(void *ctx) {
|
||||
int sock = *(int *)ctx;
|
||||
closesocket(sock);
|
||||
}
|
||||
|
||||
int esp_ctx = -1;
|
||||
ha_transport_t transport = {
|
||||
.connect = esp_connect,
|
||||
.send = esp_send,
|
||||
.recv = esp_recv,
|
||||
.close = esp_close,
|
||||
.ctx = &esp_ctx,
|
||||
};
|
||||
```
|
||||
|
||||
**Scenario B: Serial (UART) passthrough module**
|
||||
|
||||
```c
|
||||
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||||
(void)host; (void)port;
|
||||
return uart_init((uart_ctx_t *)ctx, 115200);
|
||||
}
|
||||
|
||||
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||||
return uart_write((uart_ctx_t *)ctx, data, len);
|
||||
}
|
||||
|
||||
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||||
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||||
}
|
||||
|
||||
static void uart_close(void *ctx) {
|
||||
uart_deinit((uart_ctx_t *)ctx);
|
||||
}
|
||||
```
|
||||
|
||||
> Note: For UART passthrough, a TCP bridge program must run on the other end to forward serial data to the HomeAgent WebSocket port.
|
||||
|
||||
#### Step 3: Declare Device Capabilities and Command Handlers
|
||||
|
||||
```c
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* Declare device capabilities */
|
||||
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||||
|
||||
/* Handle camerasue command (take photo) */
|
||||
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = args[0] ? atoi(args) : 0;
|
||||
|
||||
// Capture image, fill the result
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 image data
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* Handle shell command */
|
||||
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
result->status = 0;
|
||||
result->output = "command executed";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* Declarative command handler table */
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camera},
|
||||
{.command = "screensee", .handler = handle_camera},
|
||||
{.command = "speakeruse", .handler = handle_speaker},
|
||||
{.command = NULL}, /* terminator */
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 4: Configure and Start the Client
|
||||
|
||||
```c
|
||||
ha_config_t config = {
|
||||
.transport = transport, // Transport layer implementation
|
||||
.server = "192.168.1.100:9890", // HomeAgent server address
|
||||
.token = "ha-dev-token-xxxxx", // Token from Step 1
|
||||
.device = {
|
||||
.device_id = "esp32-cam-1",
|
||||
.name = "Front Door Camera",
|
||||
.kind = "camera",
|
||||
.caps = caps,
|
||||
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||||
},
|
||||
.handlers = handlers, // Command handler table
|
||||
.on_binary = on_binary_data, // Receive TTS audio etc.
|
||||
.on_state = on_state_change, // Connection state callback
|
||||
.ping_interval = 30,
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
ha_status_t ret = ha_client_start(client);
|
||||
if (ret != HA_OK) {
|
||||
printf("Device connection failed: %d\n", ret);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Main loop */
|
||||
while (1) {
|
||||
ha_client_process(client); // Process protocol frames, heartbeats, commands
|
||||
|
||||
/* Optional: device-initiated event reporting */
|
||||
ha_client_send_event(client, "motion_detected",
|
||||
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||||
|
||||
/* Optional: report device status */
|
||||
ha_client_send_status(client, "online");
|
||||
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS); // RTOS-style delay
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 5: Verify the Connection
|
||||
|
||||
Check if the device is online on the HomeAgent server:
|
||||
|
||||
```bash
|
||||
# List registered devices
|
||||
curl http://<homeagent-server>:8080/api/v1/device/list
|
||||
# Expected output includes: {"device_id":"esp32-cam-1","status":"online",...}
|
||||
|
||||
# Send a command to the device (test camerasue)
|
||||
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cmd":"camerasue","args":"3"}'
|
||||
# Expected: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||||
```
|
||||
|
||||
#### Step 6: Debugging Tips
|
||||
|
||||
| Issue | Check |
|
||||
|-------|-------|
|
||||
| Connection failed | Verify `server` address and port are reachable; check `token` |
|
||||
| WS handshake failed | Verify HomeAgent server WebSocket support is enabled |
|
||||
| Command not responding | Confirm the command name is registered in `handlers` table; check `on_binary` |
|
||||
| Reconnection issues | `max_reconnect` controls retry count; -1 = infinite |
|
||||
| Low memory (embedded) | Define `HA_NO_ALLOC` to disable dynamic memory allocation |
|
||||
|
||||
### Location
|
||||
|
||||
- **SDK Source**: `remotedevice/`
|
||||
- **plugindev template**: `plugindev init --type remotedevice`
|
||||
|
||||
## Building & Installing
|
||||
|
||||
@ -253,15 +593,21 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
||||
plugindev build
|
||||
```
|
||||
|
||||
Outputs a `.hmap` package to the project directory.
|
||||
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
|
||||
|
||||
### Install
|
||||
|
||||
Via pluginmgr HTTP API:
|
||||
Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
# Local path
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||||
|
||||
# Upload binary directly
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
--data-binary @dist/my-plugin.hmap
|
||||
```
|
||||
|
||||
Or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
{
|
||||
{
|
||||
"name": "a2a",
|
||||
"name_zh": "A2A 代理通信",
|
||||
"name_en": "A2A Agent Communication",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["a2a", "agent", "interop"],
|
||||
"tags": [
|
||||
"a2a",
|
||||
"agent",
|
||||
"interop"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
@ -17,17 +18,50 @@ import (
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
srvMu sync.Mutex
|
||||
server *http.Server
|
||||
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) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.sessions = make(map[string]*a2aSession)
|
||||
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{
|
||||
Key: "listen", Default: "127.0.0.1:12000",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
@ -43,6 +77,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"properties": map[string]interface{}{
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
"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"},
|
||||
},
|
||||
"required": []string{"agent_url", "query"},
|
||||
@ -97,7 +132,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
// Inbound HTTP server
|
||||
if addr, _ := s.Settings().Get("listen"); addr != nil {
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
p.startServer(addrStr)
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
log.Printf("[%s] start A2A server: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,7 +147,69 @@ func (p *Plugin) Stop() error {
|
||||
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() {
|
||||
p.srvMu.Lock()
|
||||
defer p.srvMu.Unlock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
p.server = nil
|
||||
@ -120,7 +219,7 @@ func (p *Plugin) stopServer() {
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) {
|
||||
func (p *Plugin) startServer(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||
@ -128,18 +227,32 @@ func (p *Plugin) startServer(addr string) {
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("[%s] listen %s: %v", p.name, addr, err)
|
||||
return
|
||||
return fmt.Errorf("listen %s: %v", addr, err)
|
||||
}
|
||||
|
||||
p.server = &http.Server{Handler: mux}
|
||||
p.serverAddr = listener.Addr().String()
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
addrStr := listener.Addr().String()
|
||||
|
||||
p.srvMu.Lock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
p.server = srv
|
||||
p.serverAddr = addrStr
|
||||
p.srvMu.Unlock()
|
||||
|
||||
go func() {
|
||||
log.Printf("[%s] A2A server on %s", p.name, p.serverAddr)
|
||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] A2A server on %s", p.name, addrStr)
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||
@ -171,7 +284,9 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
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 {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
@ -195,29 +310,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
queryText = strings.TrimSpace(queryText)
|
||||
}
|
||||
|
||||
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
||||
if queryText != "" {
|
||||
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
||||
if queryText == "" {
|
||||
http.Error(w, "query/message.text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"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")
|
||||
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")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"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:
|
||||
@ -261,9 +443,10 @@ type A2ARequest struct {
|
||||
}
|
||||
|
||||
type A2AParams struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type A2AResponse struct {
|
||||
@ -276,6 +459,7 @@ type A2AResponse struct {
|
||||
type A2AResult struct {
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||
}
|
||||
@ -341,6 +525,7 @@ func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, er
|
||||
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||
agentURL, _ := args["agent_url"].(string)
|
||||
query, _ := args["query"].(string)
|
||||
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||
timeoutSec := 60
|
||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||
timeoutSec = int(v)
|
||||
@ -362,7 +547,8 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
||||
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||
Method: "tasks.send",
|
||||
Params: A2AParams{
|
||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||
SessionID: sessionID,
|
||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||
},
|
||||
}
|
||||
|
||||
@ -401,34 +587,39 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
||||
replyText = strings.TrimSpace(replyText)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||
"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 ----
|
||||
|
||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||
listen, _ := args["listen"].(string)
|
||||
if listen == "" {
|
||||
return "参数 listen 不能为空。设为空字符串可禁用 HTTP 服务。", nil
|
||||
}
|
||||
listen = strings.TrimSpace(listen)
|
||||
|
||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||
}
|
||||
|
||||
p.stopServer()
|
||||
if listen != "" {
|
||||
p.startServer(listen)
|
||||
if listen == "" || listen == "off" || listen == "disabled" {
|
||||
p.stopServer()
|
||||
return "A2A HTTP 服务已禁用(listen 设为空)", nil
|
||||
}
|
||||
|
||||
status := "已启动"
|
||||
if listen == "" {
|
||||
status = "已禁用"
|
||||
if err := p.startServer(listen); err != nil {
|
||||
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (%s)", listen, status), nil
|
||||
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
|
||||
@ -436,23 +627,28 @@ func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
if addrStr == "" {
|
||||
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
|
||||
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
|
||||
}
|
||||
|
||||
p.startServer(addrStr)
|
||||
if p.server == nil {
|
||||
return fmt.Sprintf("A2A 服务启动失败,请检查监听地址: %s", addrStr), nil
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("A2A 服务已重启,监听: %s", p.serverAddr), nil
|
||||
|
||||
p.srvMu.Lock()
|
||||
listening := p.serverAddr
|
||||
p.srvMu.Unlock()
|
||||
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
|
||||
p.srvMu.Lock()
|
||||
serverRunning := p.server != nil
|
||||
listening := p.serverAddr
|
||||
p.srvMu.Unlock()
|
||||
if !serverRunning {
|
||||
listening = "未运行"
|
||||
}
|
||||
|
||||
7
example/acp/go.mod
Normal file
7
example/acp/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module acp
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/acp/main.go
Normal file
11
example/acp/main.go
Normal 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)
|
||||
}
|
||||
19
example/acp/plg.json
Normal file
19
example/acp/plg.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "acp",
|
||||
"name_zh": "ACP 代理通信",
|
||||
"name_en": "ACP Agent Client Protocol",
|
||||
"version": "1.2.0",
|
||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": [
|
||||
"acp",
|
||||
"agent",
|
||||
"interop"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
642
example/acp/plugin.go
Normal file
642
example/acp/plugin.go
Normal file
@ -0,0 +1,642 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
|
||||
// - 服务端:POST /api/session (JSON-RPC:session/new / session/update),
|
||||
// 请求注入本 Agent,另提供 GET /api/session?id=xxx SSE 事件流。
|
||||
// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
srvMu sync.Mutex
|
||||
server *http.Server
|
||||
serverID string
|
||||
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*sessionState
|
||||
}
|
||||
|
||||
type sessionState struct {
|
||||
ID string
|
||||
Replying []map[string]interface{}
|
||||
History []string // 轮次历史 [user, agent, user, agent...],延续上下文用
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// maxSessionTurns 单会话保留的最大轮次对数。
|
||||
const maxSessionTurns = 10
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.sessions = make(map[string]*sessionState)
|
||||
tp := p.name + "_"
|
||||
|
||||
// 注册自身为输出通道: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{
|
||||
Key: "listen", Default: "127.0.0.1:12001",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
s.RegisterTool(tp+"acp_query", sdk.ToolDef{
|
||||
Name: tp + "acp_query", Description: "向远程 ACP Agent(如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001)发起一个会话请求并等待回复,返回其最终回答文本,兼容 SSE 型与同步 JSON 型 ACP 服务端",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
||||
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
|
||||
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
|
||||
},
|
||||
"required": []string{"server_url", "prompt"},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct {
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" {
|
||||
return r.Reply
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleAcpQuery)
|
||||
|
||||
s.RegisterTool(tp+"acp_configure", sdk.ToolDef{
|
||||
Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001,设为空禁用)"},
|
||||
},
|
||||
},
|
||||
}, p.handleConfigure)
|
||||
|
||||
s.RegisterTool(tp+"acp_status", sdk.ToolDef{
|
||||
Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleStatus)
|
||||
|
||||
addr, _ := s.Settings().Get("listen")
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
log.Printf("[%s] start ACP server: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[%s] started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopServer()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) stopServer() {
|
||||
p.srvMu.Lock()
|
||||
defer p.srvMu.Unlock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
p.server = nil
|
||||
p.serverID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/session", p.handleSession)
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %v", addr, err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
addrStr := listener.Addr().String()
|
||||
|
||||
p.srvMu.Lock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
p.server = srv
|
||||
p.serverID = addrStr
|
||||
p.srvMu.Unlock()
|
||||
|
||||
go func() {
|
||||
log.Printf("[%s] ACP server on %s", p.name, addrStr)
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
p.handleSessionPost(w, r)
|
||||
case "GET":
|
||||
p.handleSessionSSE(w, r)
|
||||
default:
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionPost 处理 JSON-RPC:session/new 与 session/update
|
||||
func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Request *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"request,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Final bool `json:"final,omitempty"`
|
||||
} `json:"params,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "session/new":
|
||||
text := ""
|
||||
if req.Params.Request != nil {
|
||||
text = strings.TrimSpace(req.Params.Request.Text)
|
||||
}
|
||||
if text == "" {
|
||||
http.Error(w, "request.text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 会话:调用方可指定 session_id 延续多轮;不指定则新建。
|
||||
sid := strings.TrimSpace(req.Params.SessionID)
|
||||
p.mu.Lock()
|
||||
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()
|
||||
|
||||
// 延续上下文
|
||||
injectText := 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")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"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,
|
||||
},
|
||||
})
|
||||
|
||||
case "session/update":
|
||||
sid := req.Params.SessionID
|
||||
p.mu.Lock()
|
||||
st := p.sessions[sid]
|
||||
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")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"final": true},
|
||||
})
|
||||
|
||||
case "session/cancel":
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, req.Params.SessionID)
|
||||
p.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"canceled": true},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionSSE 提供 SSE 事件流订阅
|
||||
func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) {
|
||||
sid := r.URL.Query().Get("id")
|
||||
if sid == "" {
|
||||
http.Error(w, "id query param required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
st := p.sessions[sid]
|
||||
p.mu.RUnlock()
|
||||
if st == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
p.mu.RLock()
|
||||
replies := append([]map[string]interface{}{}, st.Replying...)
|
||||
p.mu.RUnlock()
|
||||
for _, rep := range replies {
|
||||
data, _ := json.Marshal(rep)
|
||||
fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data)
|
||||
fl.Flush()
|
||||
}
|
||||
p.mu.Lock()
|
||||
st.Replying = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Outbound:ACP 客户端 ----
|
||||
|
||||
// parseRPCBody 兼容 JSON 与 SSE 两种响应体
|
||||
func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) {
|
||||
if strings.Contains(ct, "text/event-stream") {
|
||||
sc := bufio.NewScanner(bytes.NewReader(body))
|
||||
var last string
|
||||
for sc.Scan() {
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data != "" && data != "[DONE]" {
|
||||
last = data
|
||||
}
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200))
|
||||
}
|
||||
body = []byte(last)
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300))
|
||||
}
|
||||
return &raw, nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) {
|
||||
serverURL, _ := args["server_url"].(string)
|
||||
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
|
||||
if serverURL == "" {
|
||||
return map[string]interface{}{"error": "server_url 不能为空"}, nil
|
||||
}
|
||||
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
|
||||
serverURL = "http://" + serverURL
|
||||
}
|
||||
prompt, _ := args["prompt"].(string)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
||||
}
|
||||
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||
timeoutSec := 120
|
||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||
timeoutSec = int(v)
|
||||
}
|
||||
|
||||
endpoint := serverURL + "/api/session"
|
||||
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"request": map[string]interface{}{"text": prompt},
|
||||
}
|
||||
if sessionID != "" {
|
||||
params["session_id"] = sessionID
|
||||
}
|
||||
newBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
"method": "session/new",
|
||||
"params": params,
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil
|
||||
}
|
||||
|
||||
raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
var rpcResp struct {
|
||||
Result *struct {
|
||||
Session *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"session,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
} `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(*raw, &rpcResp); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
if rpcResp.Error != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil
|
||||
}
|
||||
if rpcResp.Result == nil {
|
||||
return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
|
||||
// 兼容两种协议:
|
||||
// A) 标准/SSE 型(opencode、本插件服务端):result.session.id,回复经 SSE 事件流
|
||||
// B) 同步 JSON 型(pi bridge):result.sessionId + result.reply
|
||||
if rpcResp.Result.Reply != "" {
|
||||
return map[string]interface{}{
|
||||
"session_id": rpcResp.Result.SessionID,
|
||||
"status": "completed",
|
||||
"reply": rpcResp.Result.Reply,
|
||||
}, nil
|
||||
}
|
||||
if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" {
|
||||
return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
sid := rpcResp.Result.Session.ID
|
||||
|
||||
replyText := p.readSSEReply(endpoint, sid, client, timeoutSec)
|
||||
|
||||
return map[string]interface{}{
|
||||
"session_id": sid,
|
||||
"status": "completed",
|
||||
"reply": replyText,
|
||||
"note": "延续会话:下次调用传此 session_id 可保持上下文",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本
|
||||
func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string {
|
||||
sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid)
|
||||
req, _ := http.NewRequest("GET", sseURL, nil)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(SSE 读取失败: %v)", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bb, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
for sc.Scan() {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var evt struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Message *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"message,omitempty"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &evt) == nil {
|
||||
text := evt.Text
|
||||
if evt.Message != nil && evt.Message.Text != "" {
|
||||
text = evt.Message.Text
|
||||
}
|
||||
if text != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "(未收到回复)"
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ---- Management ----
|
||||
|
||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||
listen, _ := args["listen"].(string)
|
||||
listen = strings.TrimSpace(listen)
|
||||
|
||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||
}
|
||||
|
||||
if listen == "" || listen == "off" || listen == "disabled" {
|
||||
p.stopServer()
|
||||
return "ACP HTTP 服务已禁用", nil
|
||||
}
|
||||
|
||||
if err := p.startServer(listen); err != nil {
|
||||
return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
|
||||
p.srvMu.Lock()
|
||||
serverRunning := p.server != nil
|
||||
listening := p.serverID
|
||||
p.srvMu.Unlock()
|
||||
|
||||
p.mu.RLock()
|
||||
n := len(p.sessions)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if !serverRunning {
|
||||
listening = "未运行"
|
||||
}
|
||||
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d",
|
||||
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
{
|
||||
{
|
||||
"name": "ai_image",
|
||||
"name_zh": "AI绘图",
|
||||
"name_en": "AI Image",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["ai", "image", "draw", "generate"],
|
||||
"tags": [
|
||||
"ai",
|
||||
"image",
|
||||
"draw",
|
||||
"generate"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -21,6 +24,8 @@ type Plugin struct {
|
||||
provider string
|
||||
model string
|
||||
size string
|
||||
baseURL string
|
||||
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||
}
|
||||
|
||||
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",
|
||||
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{
|
||||
Key: "provider", Default: "openai", Type: "string",
|
||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||
Category: "ai_image",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
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.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||||
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 + "_"
|
||||
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{}{
|
||||
"type": "object",
|
||||
"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) {
|
||||
// 上游地址: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{
|
||||
Model: model,
|
||||
Prompt: prompt,
|
||||
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
||||
ResponseFormat: "url",
|
||||
}
|
||||
|
||||
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
|
||||
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("Authorization", "Bearer "+apiKey)
|
||||
|
||||
@ -247,14 +279,74 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
||||
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{}{
|
||||
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
|
||||
"images": urls,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"content": content,
|
||||
"images": localPaths,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"local_paths": localPaths,
|
||||
}, 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 {
|
||||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||||
Width int `json:"width"`
|
||||
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
|
||||
}
|
||||
|
||||
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,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
{
|
||||
{
|
||||
"name": "bili",
|
||||
"name_zh": "B站视频下载",
|
||||
"name_en": "Bilibili Video Downloader",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["bili", "video", "download"],
|
||||
"tags": [
|
||||
"bili",
|
||||
"video",
|
||||
"download"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -8,13 +8,15 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
proxy string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -30,6 +32,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Description: "B站视频下载后的保存目录",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "proxy", Default: "",
|
||||
Type: "string", DisplayName: "HTTP 代理",
|
||||
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
|
||||
Category: p.name,
|
||||
})
|
||||
if v, _ := s.Settings().Get("proxy"); v != nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.proxy = str
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||
Name: tp + "video",
|
||||
@ -94,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)
|
||||
|
||||
var out bytes.Buffer
|
||||
@ -101,7 +122,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
cmd.Env = proxyEnv(p.proxy)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
@ -171,12 +192,17 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(taskDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir task dir: %w", err)
|
||||
}
|
||||
|
||||
dlArgs := []string{
|
||||
"--no-warnings",
|
||||
"--socket-timeout", "30",
|
||||
"--retries", "3",
|
||||
"--fragment-retries", "3",
|
||||
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
||||
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
|
||||
"--no-overwrites",
|
||||
}
|
||||
if format != "" {
|
||||
@ -184,7 +210,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
}
|
||||
dlArgs = append(dlArgs, url)
|
||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
||||
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
cmd2.Env = proxyEnv(p.proxy)
|
||||
var dlOut bytes.Buffer
|
||||
cmd2.Stdout = &dlOut
|
||||
cmd2.Stderr = &dlOut
|
||||
@ -192,9 +218,18 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(outputDir)
|
||||
var newest string
|
||||
var newestTime int64
|
||||
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
|
||||
for _, f := range parts {
|
||||
os.Remove(f)
|
||||
}
|
||||
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
|
||||
for _, f := range residuals {
|
||||
os.Remove(f)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(taskDir)
|
||||
var mainFile string
|
||||
var mainSize int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
@ -203,30 +238,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
if fi == nil {
|
||||
continue
|
||||
}
|
||||
t := fi.ModTime().Unix()
|
||||
if t > newestTime {
|
||||
newestTime = t
|
||||
newest = e.Name()
|
||||
if fi.Size() > mainSize {
|
||||
mainSize = fi.Size()
|
||||
mainFile = e.Name()
|
||||
}
|
||||
}
|
||||
if newest == "" {
|
||||
if mainFile == "" {
|
||||
return map[string]interface{}{
|
||||
"content": "下载完成,但未找到视频文件",
|
||||
}, nil
|
||||
}
|
||||
dlPath := filepath.Join(outputDir, newest)
|
||||
fi, _ := os.Stat(dlPath)
|
||||
var fileSize int64
|
||||
if fi != nil {
|
||||
fileSize = fi.Size()
|
||||
}
|
||||
dlPath := filepath.Join(taskDir, mainFile)
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
|
||||
"file": dlPath,
|
||||
"filename": newest,
|
||||
"filename": mainFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func proxyEnv(proxy string) []string {
|
||||
env := os.Environ()
|
||||
if proxy != "" {
|
||||
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
|
||||
@ -17,10 +17,10 @@ require (
|
||||
golang.org/x/sys v0.16.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
{
|
||||
{
|
||||
"name": "browser",
|
||||
"name_zh": "浏览器",
|
||||
"name_en": "Browser",
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.0",
|
||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
||||
"tags": [
|
||||
"web",
|
||||
"search",
|
||||
"fetch",
|
||||
"browser",
|
||||
"cdp"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -33,22 +34,49 @@ type Plugin struct {
|
||||
proxy string
|
||||
client *http.Client
|
||||
|
||||
sessions map[string]*BrowserSession
|
||||
nextID int
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
sessions map[string]*BrowserSession
|
||||
nextID int
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
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 {
|
||||
id string
|
||||
allocCtx context.Context
|
||||
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context
|
||||
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||
createdAt time.Time
|
||||
timeout time.Duration
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
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) {
|
||||
@ -186,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.proxy = readCfg(s.Settings(), "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 + "_"
|
||||
|
||||
cleaner := func(output string) string {
|
||||
@ -241,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||
Name: tp + "start",
|
||||
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。",
|
||||
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
||||
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||
},
|
||||
},
|
||||
}, p.handleBrowserStart)
|
||||
@ -273,7 +309,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
|
||||
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
|
||||
"format": map[string]interface{}{"type": "string", "description": "图片格式: png 或 jpeg(默认 png)"},
|
||||
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
@ -336,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
}, p.handleScroll)
|
||||
|
||||
s.RegisterTool(tp+"install", sdk.ToolDef{
|
||||
Name: tp + "install",
|
||||
Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleBrowserInstall)
|
||||
|
||||
s.RegisterTool(tp+"close", sdk.ToolDef{
|
||||
Name: tp + "close",
|
||||
Description: "关闭交互式浏览器会话,释放资源。",
|
||||
@ -356,18 +401,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
if p.client != nil {
|
||||
p.client.CloseIdleConnections()
|
||||
}
|
||||
p.mu.Lock()
|
||||
for _, s := range p.sessions {
|
||||
s.Close()
|
||||
}
|
||||
p.sessions = nil
|
||||
p.mu.Unlock()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
p.stopOnce.Do(func() {
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
if p.client != nil {
|
||||
p.client.CloseIdleConnections()
|
||||
}
|
||||
p.mu.Lock()
|
||||
for _, s := range p.sessions {
|
||||
s.Close()
|
||||
}
|
||||
p.sessions = nil
|
||||
p.mu.Unlock()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -660,38 +707,82 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。
|
||||
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
|
||||
// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。
|
||||
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||
rawURL := readArg(args, "url", "")
|
||||
if rawURL == "" {
|
||||
return errResult("url is required"), nil
|
||||
}
|
||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||
if waitSec > 0 {
|
||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
||||
if err := p.ssrfCheck(rawURL); err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
var html string
|
||||
chromiumPath := "/usr/local/bin/chromium"
|
||||
if _, err := os.Stat(chromiumPath); err == nil {
|
||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if !rendered {
|
||||
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
|
||||
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errResult("chromium: " + err.Error()), nil
|
||||
done := make(chan error, 1)
|
||||
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()
|
||||
} 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)
|
||||
origLen := len(text)
|
||||
truncated := origLen > 5000
|
||||
@ -706,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
||||
if truncated {
|
||||
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 (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||
timeoutStr := readArg(args, "timeout", "10m")
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
func cdpReachable(endpoint string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(endpoint + "/json/version")
|
||||
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[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
@ -727,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
if p.proxy != "" {
|
||||
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
||||
}
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
ctx, _ := chromedp.NewContext(allocCtx)
|
||||
|
||||
// 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程
|
||||
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
|
||||
if err := chromedp.Run(ctx); err != nil {
|
||||
cancel()
|
||||
return errResult("browser init failed: " + err.Error()), nil
|
||||
cancelAlloc()
|
||||
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{
|
||||
allocCtx: allocCtx,
|
||||
cancel: cancel,
|
||||
ctx: ctx,
|
||||
createdAt: time.Now(),
|
||||
timeout: timeout,
|
||||
source := readArg(args, "source", "")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
|
||||
// 同 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()
|
||||
@ -755,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
|
||||
initURL := readArg(args, "url", "")
|
||||
if initURL != "" {
|
||||
if err := chromedp.Run(ctx,
|
||||
if err := chromedp.Run(session.ctx,
|
||||
chromedp.Navigate(initURL),
|
||||
chromedp.WaitReady("body"),
|
||||
); err != nil {
|
||||
@ -766,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
return errResult("navigate failed: " + err.Error()), nil
|
||||
}
|
||||
session.currentURL = initURL
|
||||
p.sdk.InjectText(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{}{
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"mode": "shared-backend",
|
||||
"url": initURL,
|
||||
"timeout": timeout.String(),
|
||||
}, nil
|
||||
@ -807,7 +1011,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
|
||||
return errResult("navigate failed: " + err.Error()), nil
|
||||
}
|
||||
s.currentURL = rawURL
|
||||
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))
|
||||
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))
|
||||
return map[string]interface{}{"status": "ok", "url": rawURL}, nil
|
||||
}
|
||||
|
||||
@ -825,6 +1029,9 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
||||
full = v
|
||||
}
|
||||
format := readArg(args, "format", "png")
|
||||
if format != "png" {
|
||||
return errResult("仅支持 png 格式"), nil
|
||||
}
|
||||
var buf []byte
|
||||
var err error
|
||||
if full {
|
||||
@ -841,7 +1048,7 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
||||
"format": format,
|
||||
"size": len(buf),
|
||||
"base64": b64,
|
||||
"data_uri": fmt.Sprintf("data:image/%s;base64,%s", format, b64),
|
||||
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -1001,12 +1208,113 @@ func (p *Plugin) cleanupLoop() {
|
||||
for id, s := range p.sessions {
|
||||
if time.Since(s.createdAt) >= s.timeout {
|
||||
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
|
||||
delete(p.sessions, id)
|
||||
go s.Close()
|
||||
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
||||
delete(p.sessions, id)
|
||||
s.Close()
|
||||
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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": "请先安装 chromium:apt 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" +
|
||||
"全机共享说明:本机所有 agent(HomeAgent、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
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
{
|
||||
{
|
||||
"name": "calendar",
|
||||
"name_zh": "日历",
|
||||
"name_en": "Calendar",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "日历事件管理,支持提醒和重复事件",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
||||
"tags": [
|
||||
"calendar",
|
||||
"event",
|
||||
"reminder",
|
||||
"schedule"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -134,6 +134,18 @@ func readArg[T string | int64 | float64](args map[string]interface{}, key string
|
||||
return fallback
|
||||
}
|
||||
|
||||
func readArgBool(args map[string]interface{}, key string) bool {
|
||||
if v, ok := args[key]; ok && v != nil {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s == "1" || strings.EqualFold(s, "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Time Helpers ---
|
||||
|
||||
var shortWeekday = map[time.Weekday]string{
|
||||
@ -185,14 +197,14 @@ func daysInLunarYear(year int) int {
|
||||
}
|
||||
y := lunarInfo[year-1900]
|
||||
sum := 0
|
||||
for i := 0x8000; i > 0; i >>= 1 {
|
||||
for i := 0x8000; i > 0x8; i >>= 1 {
|
||||
if y&i > 0 {
|
||||
sum += 30
|
||||
} else {
|
||||
sum += 29
|
||||
}
|
||||
}
|
||||
return sum
|
||||
return sum + leapDays(year)
|
||||
}
|
||||
|
||||
func leapMonth(year int) int {
|
||||
@ -236,11 +248,9 @@ func lunarToSolar(year, month, day int) (time.Time, bool) {
|
||||
offset += daysInLunarYear(y)
|
||||
}
|
||||
lm := leapMonth(year)
|
||||
_ = lm
|
||||
for m := 1; m < month; m++ {
|
||||
offset += monthDays(year, m)
|
||||
if m == lm {
|
||||
offset += leapDays(year)
|
||||
}
|
||||
}
|
||||
offset += day - 1
|
||||
solar := baseSolar.AddDate(0, 0, offset)
|
||||
@ -254,7 +264,7 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t.After(after) || t.Equal(after) {
|
||||
if t.After(after) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
@ -266,14 +276,22 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
dataHome := os.Getenv("HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = "/tmp"
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
}
|
||||
p.dataDir = filepath.Join(dataHome, ".homeagent", "calendar")
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
p.loadEvents()
|
||||
|
||||
// 持久化交由 stop handler:内核会在调用 Stop() 之前执行,
|
||||
// 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。
|
||||
s.RegisterStopHandler(p.saveEvents)
|
||||
// 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"event_add", sdk.ToolDef{
|
||||
@ -388,7 +406,6 @@ func (p *Plugin) Stop() error {
|
||||
p.remindTicker.Stop()
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
p.saveEvents()
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
return nil
|
||||
}
|
||||
@ -411,9 +428,9 @@ func (p *Plugin) checkReminders() {
|
||||
now := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
changed := false
|
||||
var injectMsgs []string
|
||||
|
||||
for i := range p.events {
|
||||
e := &p.events[i]
|
||||
@ -455,7 +472,7 @@ func (p *Plugin) checkReminders() {
|
||||
if e.Note != "" {
|
||||
msg += fmt.Sprintf("\n📝 %s", e.Note)
|
||||
}
|
||||
go p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
||||
injectMsgs = append(injectMsgs, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@ -479,8 +496,17 @@ func (p *Plugin) checkReminders() {
|
||||
pid = e.ParentID
|
||||
}
|
||||
next.ParentID = pid
|
||||
newEvents = append(newEvents, *next)
|
||||
changed = true
|
||||
dup := false
|
||||
for _, ev := range p.events {
|
||||
if ev.ID != e.ID && ev.ParentID == pid && ev.StartTime == next.StartTime {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
newEvents = append(newEvents, *next)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(newEvents) > 0 {
|
||||
@ -491,6 +517,11 @@ func (p *Plugin) checkReminders() {
|
||||
if changed {
|
||||
p.saveEventsLocked()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
for _, msg := range injectMsgs {
|
||||
p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent {
|
||||
@ -560,9 +591,7 @@ func (p *Plugin) cleanupPastEvents() {
|
||||
keep = append(keep, e)
|
||||
continue
|
||||
}
|
||||
if e.Repeat != "" && e.Repeat != RepeatNone {
|
||||
keep = append(keep, e)
|
||||
}
|
||||
_ = e // 过时重复事件不再保留:next 已由 nextOccurrence 追加
|
||||
}
|
||||
p.events = keep
|
||||
}
|
||||
@ -573,6 +602,17 @@ func (p *Plugin) eventsFile() string {
|
||||
return filepath.Join(p.dataDir, "events.json")
|
||||
}
|
||||
|
||||
// cleanupData 删除插件时清理本地持久化数据文件。
|
||||
func (p *Plugin) cleanupData() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) loadEvents() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@ -616,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
|
||||
NextEventID: p.nextEventID,
|
||||
}
|
||||
b, _ := json.MarshalIndent(data, "", " ")
|
||||
os.WriteFile(p.eventsFile(), b, 0644)
|
||||
atomicWriteJSON(p.eventsFile(), b)
|
||||
}
|
||||
|
||||
// --- Helper: parse remind_before ---
|
||||
@ -692,10 +732,7 @@ func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error
|
||||
note := readArg(args, "note", "")
|
||||
remindStr := readArg(args, "remind_before", "")
|
||||
reminds := parseReminds(remindStr)
|
||||
lunar := false
|
||||
if v := readArg(args, "lunar", ""); v == "true" {
|
||||
lunar = true
|
||||
}
|
||||
lunar := readArgBool(args, "lunar")
|
||||
lunarMonth := int(readArg(args, "lunar_month", int64(0)))
|
||||
lunarDay := int(readArg(args, "lunar_day", int64(0)))
|
||||
|
||||
@ -897,10 +934,12 @@ func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, er
|
||||
e.Repeat = v
|
||||
}
|
||||
}
|
||||
if v := readArg(args, "lunar", ""); v == "true" {
|
||||
e.Lunar = true
|
||||
} else if v == "false" {
|
||||
e.Lunar = false
|
||||
if v, ok := args["lunar"]; ok && v != nil {
|
||||
if b, ok := v.(bool); ok {
|
||||
e.Lunar = b
|
||||
} else if s, ok := v.(string); ok {
|
||||
e.Lunar = s == "1" || strings.EqualFold(s, "true")
|
||||
}
|
||||
}
|
||||
if v := readArg(args, "lunar_month", int64(0)); v > 0 {
|
||||
e.LunarMonth = int(v)
|
||||
@ -1138,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "editdoc",
|
||||
"name_zh": "文档编辑",
|
||||
"name_en": "Document Editor",
|
||||
|
||||
@ -4,15 +4,19 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
scriptPath string
|
||||
venvPython string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -20,6 +24,30 @@ func (p *Plugin) Name() string { return p.name }
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "script_path", Default: "", Type: "string",
|
||||
DisplayName: "编辑脚本路径",
|
||||
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "venv_python", Default: "", Type: "string",
|
||||
DisplayName: "venv Python 解释器",
|
||||
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
if v, err := s.Settings().Get("script_path"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.scriptPath = str
|
||||
}
|
||||
}
|
||||
if v, err := s.Settings().Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = str
|
||||
}
|
||||
}
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||
@ -80,19 +108,24 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
|
||||
}
|
||||
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||
|
||||
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py"
|
||||
scriptPath := p.scriptPath
|
||||
if scriptPath == "" {
|
||||
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
|
||||
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
|
||||
}
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath)
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
|
||||
}
|
||||
|
||||
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3"
|
||||
pythonBin := "python3"
|
||||
if _, err := os.Stat(venvPython); err == nil {
|
||||
pythonBin = venvPython
|
||||
if p.venvPython == "" {
|
||||
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
|
||||
}
|
||||
if _, err := os.Stat(p.venvPython); err != nil {
|
||||
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("edit document: %w", err)
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "files",
|
||||
"name_zh": "文件系统",
|
||||
"name_en": "File System",
|
||||
|
||||
@ -27,24 +27,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "dir",
|
||||
Default: "/",
|
||||
Default: "",
|
||||
Type: "string",
|
||||
DisplayName: "文件系统根目录",
|
||||
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||
Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox),不建议设为 /",
|
||||
Category: "files",
|
||||
})
|
||||
|
||||
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||
dir := getSetting[string](s.Settings(), "dir", "")
|
||||
if strings.HasPrefix(dir, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, dir[2:])
|
||||
}
|
||||
if dir == "" {
|
||||
dataDir, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
base := "."
|
||||
if err == nil {
|
||||
if ds, ok := dataDir.(string); ok && ds != "" {
|
||||
base = ds
|
||||
}
|
||||
}
|
||||
dir = filepath.Join(base, "files_sandbox")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve files.dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir files.dir: %w", err)
|
||||
}
|
||||
if real, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = real
|
||||
}
|
||||
p.filesDir = abs
|
||||
os.MkdirAll(p.filesDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
@ -143,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
base := filepath.Clean(p.filesDir)
|
||||
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||
if !withinSandbox(base, abs) {
|
||||
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||
}
|
||||
return abs, nil
|
||||
real, err := evalReal(base, abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !withinSandbox(base, real) {
|
||||
return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
func withinSandbox(base, abs string) bool {
|
||||
if base == "/" {
|
||||
return true
|
||||
}
|
||||
return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func evalReal(base, abs string) (string, error) {
|
||||
existing := abs
|
||||
var tail []string
|
||||
for {
|
||||
real, err := filepath.EvalSymlinks(existing)
|
||||
if err == nil {
|
||||
full := real
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
full = filepath.Join(full, tail[i])
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
if link, lerr := os.Readlink(existing); lerr == nil {
|
||||
target := link
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(existing), target)
|
||||
}
|
||||
if t, aerr := filepath.Abs(target); aerr == nil {
|
||||
target = filepath.Clean(t)
|
||||
}
|
||||
if !withinSandbox(base, target) {
|
||||
return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs)
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(existing)
|
||||
if parent == existing {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
tail = append(tail, filepath.Base(existing))
|
||||
existing = parent
|
||||
}
|
||||
}
|
||||
|
||||
// handleRead implements the read tool.
|
||||
|
||||
25
example/luademo/README.md
Normal file
25
example/luademo/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# luademo
|
||||
|
||||
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
|
||||
|
||||
- **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤)
|
||||
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
||||
- **通道**:`register_output_channel` / `register_input_channel`(def 支持 no_memory/cleaner)
|
||||
- **数据类 API**:`sdk.memory.*`、`sdk.doc.*`、`sdk.knowledge.*`、`sdk.text_memory.*`、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`
|
||||
- **其他**:`register_api`、`set_auto_restart`
|
||||
|
||||
## 本地独立测试
|
||||
|
||||
```bash
|
||||
lua main.lua # 使用 sdk.lua mock,不依赖内核
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
通过插件管理 HTTP API 上传 `.hmap` 包,或解压到 `<data>/plugins/luademo/` 后重启内核。
|
||||
105
example/luademo/main.lua
Normal file
105
example/luademo/main.lua
Normal file
@ -0,0 +1,105 @@
|
||||
-- luademo plugin — 展示 v0.8.0 Lua SDK 全部能力
|
||||
-- 运行环境:内核注入真实实现;lua main.lua 可用 sdk.lua mock 独立测试
|
||||
local plugin = { name = "luademo" }
|
||||
|
||||
function plugin.start(sdk)
|
||||
sdk.log("info", "luademo starting...")
|
||||
|
||||
-- 注册配置项(WebUI 可展示)
|
||||
sdk.settings.register_def({
|
||||
key = "plugin.luademo.greeting",
|
||||
default = "Hello",
|
||||
type = "string",
|
||||
display_name = "Greeting",
|
||||
description = "Greeting prefix for the hello tool",
|
||||
category = "luademo",
|
||||
})
|
||||
|
||||
-- 注册工具:no_memory(输出跳过记忆计算)+ cleaner(计算层过滤函数)
|
||||
sdk.register_tool("luademo_hello", {
|
||||
description = "A hello world tool with no_memory and cleaner",
|
||||
parameters = { type = "object", properties = {} },
|
||||
no_memory = true,
|
||||
cleaner = function(text) return "CLEANED:" .. text end,
|
||||
}, function(args)
|
||||
local prefix, err = sdk.settings.get_core("plugin.luademo.greeting")
|
||||
if err ~= nil then prefix = "Hello" end
|
||||
return { content = (prefix or "Hello") .. " from luademo plugin!" }
|
||||
end)
|
||||
|
||||
-- 注册工具:数据类 API 巡检(memory/doc/knowledge/text_memory/llm/settings/social)
|
||||
sdk.register_tool("luademo_probe", {
|
||||
description = "Exercise every aligned data API and return combined results",
|
||||
parameters = { type = "object", properties = {} },
|
||||
no_memory = true,
|
||||
}, function(args)
|
||||
local res = {}
|
||||
|
||||
local ok, err = sdk.memory.commit({ { subject = "demo", relation = "uses", object = "lua" } })
|
||||
res.memory_commit = { ok = ok, err = err }
|
||||
local recalled, rerr = sdk.memory.recall("demo", 1)
|
||||
res.memory_recall = { result = recalled, err = rerr }
|
||||
|
||||
ok, err = sdk.doc.insert({ id = "demo-1", title = "lua demo doc", content = "hello lua world" })
|
||||
res.doc_insert = { ok = ok, err = err }
|
||||
local docs, derr = sdk.doc.query("lua", 2)
|
||||
res.doc_query = { result = docs, err = derr }
|
||||
|
||||
ok, err = sdk.knowledge.add("luademo", "lua knowledge entry")
|
||||
res.knowledge_add = { ok = ok, err = err }
|
||||
local entries, kerr = sdk.knowledge.search("luademo", 2)
|
||||
res.knowledge_search = { result = entries, err = kerr }
|
||||
|
||||
ok, err = sdk.text_memory.append({ role = "tool", content = "luademo probe ran", channel = "luademo" })
|
||||
res.text_memory = { ok = ok, err = err }
|
||||
|
||||
local sources, serr = sdk.llm.list_sources()
|
||||
res.llm_sources = { result = sources, err = serr }
|
||||
|
||||
local v, verr = sdk.settings.get_core("agent.name")
|
||||
res.settings_get_core = { result = v, err = verr }
|
||||
local defs, defserr = sdk.settings.defs("plugin.luademo")
|
||||
res.settings_defs = { result = defs, err = defserr }
|
||||
|
||||
local persons, perr = sdk.social.list_persons()
|
||||
res.social_persons = { result = persons, err = perr }
|
||||
|
||||
return { content = res }
|
||||
end)
|
||||
|
||||
-- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发)
|
||||
sdk.register_stage("before_toolcall", function(ctx)
|
||||
local calls = ctx.tool_calls or {}
|
||||
if calls[1] then
|
||||
sdk.log("info", "luademo stage before_toolcall: tool=" .. tostring(calls[1].name))
|
||||
end
|
||||
return nil
|
||||
end, "own_tools")
|
||||
|
||||
-- 阶段钩子:全局作用域(修改 ctx 字段会写回内核,见 applyLuaStageResult)
|
||||
sdk.register_stage("pre_action", function(ctx)
|
||||
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
|
||||
-- 演示 stage 写回:给 llm_text 追加标记(内核会同步回 StageContext)
|
||||
if ctx.llm_text then
|
||||
ctx.llm_text = ctx.llm_text .. "[luademo]"
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
|
||||
-- 输出通道:路由输出到外部渠道(def 支持 no_memory/cleaner)
|
||||
sdk.register_output_channel("luademo_out", 0, "luademo push channel",
|
||||
{ no_memory = true, cleaner = function(t) return "OCLEANED:" .. t end },
|
||||
function(args) return { content = "out-channel ack" } end)
|
||||
|
||||
-- 输入通道
|
||||
sdk.register_input_channel("luademo_in", { no_memory = true })
|
||||
|
||||
-- 其他 API
|
||||
sdk.register_api("luademo.ping")
|
||||
sdk.set_auto_restart(true)
|
||||
|
||||
sdk.log("info", "luademo started")
|
||||
end
|
||||
|
||||
function plugin.stop() sdk.log("info", "luademo stopped") end
|
||||
return plugin
|
||||
11
example/luademo/plg.json
Normal file
11
example/luademo/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "luademo",
|
||||
"name_zh": "Lua 全功能示例",
|
||||
"name_en": "Lua Demo",
|
||||
"version": "0.1.0",
|
||||
"description": "Lua 插件全功能示例:工具(no_memory/cleaner) + 阶段钩子 + 通道 + 数据类 API",
|
||||
"author": "HomeAgent",
|
||||
"entry": "main.lua",
|
||||
"tags": ["luademo"],
|
||||
"targets": "lua"
|
||||
}
|
||||
67
example/luademo/sdk.lua
Normal file
67
example/luademo/sdk.lua
Normal file
@ -0,0 +1,67 @@
|
||||
-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||
sdk = {}
|
||||
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
||||
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
||||
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
|
||||
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
||||
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
|
||||
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
|
||||
function sdk.get_setting(key) return nil end
|
||||
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
||||
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
||||
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
||||
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
||||
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
|
||||
sdk.memory = {}
|
||||
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||
function sdk.memory.commit(triples) return nil end
|
||||
function sdk.memory.introspect() return {} end
|
||||
function sdk.memory.merge(source, target) return 0 end
|
||||
function sdk.memory.purge(criteria, hard) return 0 end
|
||||
sdk.doc = {}
|
||||
function sdk.doc.query(text, top_k) return {} end
|
||||
function sdk.doc.insert(doc) return nil end
|
||||
function sdk.doc.remove(id) return nil end
|
||||
function sdk.doc.stats() return {} end
|
||||
sdk.knowledge = {}
|
||||
function sdk.knowledge.search(query, limit) return {} end
|
||||
function sdk.knowledge.add(tag, content) return nil end
|
||||
function sdk.knowledge.list() return {} end
|
||||
sdk.text_memory = {}
|
||||
function sdk.text_memory.append(evt) return nil end
|
||||
sdk.llm = {}
|
||||
function sdk.llm.list_sources() return {} end
|
||||
function sdk.llm.set_source(name) return nil end
|
||||
function sdk.llm.current_source() return nil end
|
||||
sdk.social = {}
|
||||
function sdk.social.get_person(name) return {} end
|
||||
function sdk.social.get_network(name, depth) return {} end
|
||||
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||
function sdk.social.get_relations(name) return {} end
|
||||
function sdk.social.list_persons() return {} end
|
||||
sdk.settings = {}
|
||||
function sdk.settings.get_core(key) return nil end
|
||||
function sdk.settings.set_core(key, value) return nil end
|
||||
function sdk.settings.list_core(prefix) return {} end
|
||||
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||
function sdk.settings.list(prefix) return {} end
|
||||
function sdk.settings.register_def(def) return nil end
|
||||
function sdk.settings.defs(prefix) return {} end
|
||||
function sdk.settings.dump() return {} end
|
||||
function sdk.settings.plugins() return {} end
|
||||
sdk.json = {}
|
||||
function sdk.json.encode(val)
|
||||
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
||||
elseif type(val) == "table" then local parts, i = {}, 1
|
||||
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
||||
return "{" .. table.concat(parts, ",") .. "}" end
|
||||
return "null"
|
||||
end
|
||||
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||
sdk.http = {}
|
||||
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
return sdk
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
{
|
||||
{
|
||||
"name": "memo",
|
||||
"name_zh": "备忘录",
|
||||
"name_en": "Memo/Notes",
|
||||
"version": "1.0.0",
|
||||
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
|
||||
"name_en": "Memo",
|
||||
"version": "1.1.0",
|
||||
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["memo", "todo", "notes"],
|
||||
"tags": [
|
||||
"memo",
|
||||
"todo",
|
||||
"notes"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -13,20 +13,31 @@ import (
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Memo struct {
|
||||
// Todo 待办条目:会被主动提醒
|
||||
type Todo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// Memo 备忘录条目:纯记事,不主动提醒
|
||||
type Memo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
todos []Todo
|
||||
nextTID int64
|
||||
memos []Memo
|
||||
nextID int64
|
||||
filePath string
|
||||
nextMID int64
|
||||
todoPath string
|
||||
memoPath string
|
||||
stopCh chan struct{}
|
||||
tp string
|
||||
}
|
||||
@ -37,70 +48,154 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.tp = p.name + "_"
|
||||
p.stopCh = make(chan struct{})
|
||||
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
||||
p.load()
|
||||
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
|
||||
}
|
||||
p.todoPath = filepath.Join(dir, "todos.json")
|
||||
p.memoPath = filepath.Join(dir, "memos.json")
|
||||
p.loadTodos()
|
||||
p.loadMemos()
|
||||
|
||||
s.RegisterTool(p.tp+"create", sdk.ToolDef{
|
||||
Name: p.tp + "create",
|
||||
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
|
||||
// 卸载(删除)时清理数据文件;重载不触发
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
// ── 待办(会被主动提醒)──
|
||||
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
|
||||
Name: p.tp + "todo_add",
|
||||
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleCreate)
|
||||
}, p.handleTodoAdd)
|
||||
|
||||
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
|
||||
Name: p.tp + "complete",
|
||||
Description: "将指定ID的备忘标记为已完成。",
|
||||
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
|
||||
Name: p.tp + "todo_complete",
|
||||
Description: "将指定ID的待办标记为已完成(不再提醒)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
|
||||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleComplete)
|
||||
}, p.handleTodoComplete)
|
||||
|
||||
s.RegisterTool(p.tp+"list", sdk.ToolDef{
|
||||
Name: p.tp + "list",
|
||||
Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。",
|
||||
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
|
||||
Name: p.tp + "todo_list",
|
||||
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
}, p.handleTodoList)
|
||||
|
||||
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
|
||||
Name: p.tp + "todo_delete",
|
||||
Description: "删除指定ID的待办事项(包括已完成的)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleTodoDelete)
|
||||
|
||||
// ── 备忘(纯记事,不提醒)──
|
||||
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
|
||||
Name: p.tp + "memo_create",
|
||||
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleMemoCreate)
|
||||
|
||||
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
|
||||
Name: p.tp + "memo_list",
|
||||
Description: "列出所有备忘录,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleMemoList)
|
||||
|
||||
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
|
||||
Name: p.tp + "memo_delete",
|
||||
Description: "删除指定ID的备忘录。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleMemoDelete)
|
||||
|
||||
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
|
||||
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||
|
||||
go p.periodicCheck()
|
||||
|
||||
log.Printf("[%s] started, path=%s", p.name, p.filePath)
|
||||
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.save()
|
||||
p.saveTodos()
|
||||
p.saveMemos()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) load() {
|
||||
func (p *Plugin) loadTodos() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.filePath)
|
||||
data, err := os.ReadFile(p.todoPath)
|
||||
if err != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
p.todos = []Todo{}
|
||||
p.nextTID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
Todos []Todo `json:"todos"`
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.todos = []Todo{}
|
||||
p.nextTID = 1
|
||||
return
|
||||
}
|
||||
p.todos = store.Todos
|
||||
p.nextTID = store.NextID
|
||||
if p.todos == nil {
|
||||
p.todos = []Todo{}
|
||||
}
|
||||
if p.nextTID < 1 {
|
||||
p.nextTID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) loadMemos() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.memoPath)
|
||||
if err != nil {
|
||||
p.memos = []Memo{}
|
||||
p.nextMID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
@ -108,66 +203,82 @@ func (p *Plugin) load() {
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
p.memos = []Memo{}
|
||||
p.nextMID = 1
|
||||
return
|
||||
}
|
||||
p.memos = store.Memos
|
||||
p.nextID = store.NextID
|
||||
p.nextMID = store.NextID
|
||||
if p.memos == nil {
|
||||
p.memos = []Memo{}
|
||||
}
|
||||
if p.nextID < 1 {
|
||||
p.nextID = 1
|
||||
if p.nextMID < 1 {
|
||||
p.nextMID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) save() {
|
||||
func (p *Plugin) saveTodos() {
|
||||
p.mu.RLock()
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextID,
|
||||
"todos": p.todos,
|
||||
"next_id": p.nextTID,
|
||||
}, "", " ")
|
||||
os.WriteFile(p.filePath, data, 0644)
|
||||
p.mu.RUnlock()
|
||||
atomicWriteJSON(p.todoPath, data)
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingCount() int {
|
||||
func (p *Plugin) saveMemos() {
|
||||
p.mu.RLock()
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextMID,
|
||||
}, "", " ")
|
||||
p.mu.RUnlock()
|
||||
atomicWriteJSON(p.memoPath, data)
|
||||
}
|
||||
|
||||
// ── 待办:未完成计数与提醒 ──
|
||||
|
||||
func (p *Plugin) pendingTodoCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
n := 0
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
for _, t := range p.todos {
|
||||
if !t.Done {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingMemos() []Memo {
|
||||
func (p *Plugin) pendingTodos() []Todo {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var out []Memo
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
out = append(out, m)
|
||||
var out []Todo
|
||||
for _, t := range p.todos {
|
||||
if !t.Done {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
|
||||
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||
n := p.pendingCount()
|
||||
n := p.pendingTodoCount()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx.Lock()
|
||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
||||
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
|
||||
})
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
|
||||
func (p *Plugin) periodicCheck() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@ -176,19 +287,124 @@ func (p *Plugin) periodicCheck() {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
n := p.pendingCount()
|
||||
n := p.pendingTodoCount()
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if p.sdk != nil {
|
||||
p.sdk.InjectInterruptText(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
|
||||
// ── 待办工具 ──
|
||||
|
||||
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
todo := Todo{
|
||||
ID: p.nextTID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextTID++
|
||||
p.todos = append(p.todos, todo)
|
||||
p.mu.Unlock()
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
|
||||
"id": todo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.todos {
|
||||
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
|
||||
p.todos[i].Done = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
|
||||
todos := p.pendingTodos()
|
||||
if len(todos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的待办",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, t := range todos {
|
||||
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
"count": len(todos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.todos {
|
||||
if p.todos[i].ID == int64(id) {
|
||||
p.todos = append(p.todos[:i], p.todos[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 备忘工具 ──
|
||||
|
||||
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
@ -196,23 +412,22 @@ func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
p.mu.Lock()
|
||||
memo := Memo{
|
||||
ID: p.nextID,
|
||||
ID: p.nextMID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextID++
|
||||
p.nextMID++
|
||||
p.memos = append(p.memos, memo)
|
||||
p.mu.Unlock()
|
||||
p.save()
|
||||
p.saveMemos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
|
||||
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
|
||||
"id": memo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
|
||||
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
@ -221,8 +436,8 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.memos {
|
||||
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
|
||||
p.memos[i].Done = true
|
||||
if p.memos[i].ID == int64(id) {
|
||||
p.memos = append(p.memos[:i], p.memos[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@ -230,30 +445,33 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
|
||||
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.save()
|
||||
p.saveMemos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
|
||||
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
memos := p.pendingMemos()
|
||||
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.RLock()
|
||||
memos := append([]Memo{}, p.memos...)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if len(memos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的备忘",
|
||||
"content": "暂无备忘录",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, m := range memos {
|
||||
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
@ -270,5 +488,24 @@ func errorResult(msg string) map[string]interface{} {
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
// cleanupData 卸载时清理数据文件(待办 + 备忘)
|
||||
func (p *Plugin) cleanupData() {
|
||||
if p.todoPath != "" {
|
||||
os.Remove(p.todoPath)
|
||||
}
|
||||
if p.memoPath != "" {
|
||||
os.Remove(p.memoPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "music",
|
||||
"name_zh": "音乐搜索",
|
||||
"name_en": "Music Search",
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "ocr",
|
||||
"name_zh": "OCR 文字识别",
|
||||
"name_en": "OCR Text Recognition",
|
||||
|
||||
@ -2,14 +2,17 @@
|
||||
"name": "qq",
|
||||
"name_zh": "QQ消息",
|
||||
"name_en": "qq",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["qq", "messaging"],
|
||||
"tags": [
|
||||
"qq",
|
||||
"messaging"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": false,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,14 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -32,7 +34,7 @@ type ForwardRule struct {
|
||||
}
|
||||
|
||||
func rconSend(host string, port int, password, cmd string) error {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rcon dial: %w", err)
|
||||
@ -66,7 +68,7 @@ func rconPacket(id, typ int32, body string) []byte {
|
||||
b = append(b, 0) // null terminator
|
||||
b = append(b, 0) // padding
|
||||
length := 4 + 4 + len(b)
|
||||
pkt := make([]byte, 4+len(b))
|
||||
pkt := make([]byte, 12+len(b))
|
||||
binary.LittleEndian.PutUint32(pkt, uint32(length))
|
||||
binary.LittleEndian.PutUint32(pkt[4:], uint32(id))
|
||||
binary.LittleEndian.PutUint32(pkt[8:], uint32(typ))
|
||||
@ -90,7 +92,8 @@ type Plugin struct {
|
||||
napcatURL string
|
||||
remoteDir string
|
||||
filesDir string
|
||||
adminID int64
|
||||
webhookToken string
|
||||
adminIDs []int64
|
||||
botID int64
|
||||
botNickname string
|
||||
dmPolicy string
|
||||
@ -104,13 +107,149 @@ type Plugin struct {
|
||||
downloadTasks []*DownloadTask
|
||||
typingMu sync.Mutex
|
||||
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 {
|
||||
userID int64
|
||||
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) Start(s *sdk.PluginSDK) error {
|
||||
@ -119,7 +258,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
|
||||
@ -127,13 +266,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录(CQ file/image 自动下载到此目录)", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"})
|
||||
|
||||
settings := s.Settings()
|
||||
|
||||
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
|
||||
p.webhookToken = getSetting[string](settings, "webhook_token", "")
|
||||
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
|
||||
p.adminID = getSetting[int64](settings, "admin", 0)
|
||||
p.adminIDs = parseIDList(getSetting[string](settings, "admin", ""))
|
||||
p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open"))
|
||||
p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open"))
|
||||
p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", ""))
|
||||
@ -145,6 +286,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
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)
|
||||
p.fetchBotInfo()
|
||||
if p.botID == 0 {
|
||||
@ -243,6 +388,27 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
},
|
||||
}, 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{
|
||||
Name: tp + "get_groups", Description: "获取QQ群列表,可按关键词搜索群名",
|
||||
@ -279,7 +445,7 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
NoMemory: false,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"},
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"},
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"},
|
||||
},
|
||||
},
|
||||
@ -312,19 +478,19 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
Name: tp + "group_manage", Description: "QQ群综合管理。通过command参数执行各种操作:leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意:leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{"type": "string", "description": "操作命令"},
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"},
|
||||
"command": map[string]interface{}{"type": "string", "description": "操作命令"},
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"},
|
||||
"message_id": map[string]interface{}{"type": "integer", "description": "消息ID(撤回/精华)"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"},
|
||||
"card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"},
|
||||
"title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"},
|
||||
"enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"},
|
||||
"minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"},
|
||||
"count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"},
|
||||
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"},
|
||||
"card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"},
|
||||
"title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"},
|
||||
"enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"},
|
||||
"minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"},
|
||||
"count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"},
|
||||
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"},
|
||||
"reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群(kick)"},
|
||||
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"},
|
||||
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"},
|
||||
},
|
||||
},
|
||||
NoMemory: true,
|
||||
@ -334,12 +500,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
Name: tp + "friend_action", Description: "QQ好友管理:delete删除好友, block拉黑(删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意:涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"},
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
|
||||
"flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"},
|
||||
"remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"},
|
||||
"command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"},
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
|
||||
"flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"},
|
||||
"remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"},
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出(block配合)"},
|
||||
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"},
|
||||
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"},
|
||||
},
|
||||
},
|
||||
NoMemory: true,
|
||||
@ -352,12 +518,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
Cleaner: cleaner,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
|
||||
"command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"},
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
|
||||
"command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"},
|
||||
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"},
|
||||
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"},
|
||||
"file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"},
|
||||
"filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"},
|
||||
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"},
|
||||
"file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"},
|
||||
"filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"},
|
||||
},
|
||||
},
|
||||
}, p.handleGetGroupFiles)
|
||||
@ -454,6 +620,15 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.typingMu.Lock()
|
||||
for _, st := range p.typingMap {
|
||||
select {
|
||||
case <-st.stopCh:
|
||||
default:
|
||||
close(st.stopCh)
|
||||
}
|
||||
}
|
||||
p.typingMu.Unlock()
|
||||
if p.srv != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@ -475,8 +650,8 @@ func (p *Plugin) fetchBotInfo() {
|
||||
return
|
||||
}
|
||||
var info struct {
|
||||
Status string `json:"status"`
|
||||
Data *struct {
|
||||
Status string `json:"status"`
|
||||
Data *struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
} `json:"data"`
|
||||
@ -563,6 +738,34 @@ func parseIDSet(raw string) map[int64]struct{} {
|
||||
return out
|
||||
}
|
||||
|
||||
func parseIDList(raw string) []int64 {
|
||||
var out []int64
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 {
|
||||
out = append(out, n)
|
||||
continue
|
||||
}
|
||||
// 兼容历史坏数据:科学计数法存库的值(如 2.198972886e+09)
|
||||
if f, err := strconv.ParseFloat(part, 64); err == nil && f > 0 && f == math.Trunc(f) {
|
||||
out = append(out, int64(f))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Plugin) isAdmin(userID int64) bool {
|
||||
for _, id := range p.adminIDs {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAtBot checks if the message contains an @-mention of the bot.
|
||||
func (p *Plugin) isAtBot(msg interface{}) bool {
|
||||
segments, ok := msg.([]interface{})
|
||||
@ -616,7 +819,7 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool {
|
||||
case "allowlist":
|
||||
_, ok := p.groupAllowFrom[groupID]
|
||||
return ok
|
||||
default:
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -628,11 +831,6 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
|
||||
return nil
|
||||
}
|
||||
tc := &ctx.ToolCalls[0]
|
||||
if tc.Name == p.name+"_send_file" || tc.Name == p.name+"_upload_group_file" {
|
||||
if file, ok := tc.Arguments["file"].(string); ok {
|
||||
tc.Arguments["file"] = p.sensitiveFilter(file)
|
||||
}
|
||||
}
|
||||
if tc.Name == p.name+"_group_manage" {
|
||||
cmd, _ := tc.Arguments["command"].(string)
|
||||
if requiresConfirmGroupCommand(cmd) {
|
||||
@ -679,6 +877,10 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if p.webhookToken != "" && !hmac.Equal([]byte(r.Header.Get("X-Webhook-Token")), []byte(p.webhookToken)) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var evt struct {
|
||||
PostType string `json:"post_type"`
|
||||
@ -698,6 +900,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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
|
||||
text := rawCQ
|
||||
@ -722,9 +929,32 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if evt.MessageType == "group" {
|
||||
if !p.isGroupAllowed(evt.GroupID) {
|
||||
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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 {
|
||||
log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID)
|
||||
@ -732,6 +962,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -741,14 +974,26 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
outputTool := "output_send__" + p.name
|
||||
var interrupt string
|
||||
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 {
|
||||
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.adminID > 0 && evt.UserID == p.adminID {
|
||||
if p.isAdmin(evt.UserID) {
|
||||
interrupt = "【重要!老大消息】" + interrupt
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
text = stripCQRe.ReplaceAllString(text, "")
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
if text == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if highRiskRe.MatchString(text) {
|
||||
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
|
||||
}
|
||||
|
||||
if evt.MessageType == "group" && p.sdk != nil {
|
||||
rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]")
|
||||
var rules []ForwardRule
|
||||
@ -757,6 +1002,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if evt.GroupID == rule.GroupID {
|
||||
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
||||
go func(r ForwardRule, msg string) {
|
||||
defer func() { _ = recover() }()
|
||||
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)
|
||||
}
|
||||
@ -778,6 +1024,126 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ======== 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) {
|
||||
msgID, err := convInt64(args["message_id"])
|
||||
if err != nil {
|
||||
@ -787,10 +1153,24 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
|
||||
}, 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})
|
||||
if err != nil {
|
||||
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,
|
||||
"not_found": true,
|
||||
}, nil
|
||||
@ -821,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 {
|
||||
return map[string]interface{}{
|
||||
"content": "解析 NapCat 响应失败",
|
||||
"content": "解析 NapCat 响应失败(消息可能已过期)。请改用 qq_get_history 按会话拉取最近消息,或用 qq_list_chats 查看未读会话",
|
||||
"message_id": msgID,
|
||||
"not_found": true,
|
||||
}, nil
|
||||
@ -927,6 +1307,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
|
||||
|
||||
// 异步标记已读
|
||||
go func() {
|
||||
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
|
||||
if d.MessageType == "group" && d.GroupID > 0 {
|
||||
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
||||
} else if d.UserID > 0 {
|
||||
@ -943,7 +1324,7 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
||||
payload, _ := args["payload"].(string)
|
||||
rawType, _ := args["type"].(string)
|
||||
meta, _ := args["meta"].(string)
|
||||
log.Printf("[qq] handleChannelOutput payload=%q type=%s meta=%s", payload, rawType, meta)
|
||||
log.Printf("[qq] handleChannelOutput type=%s payload_len=%d meta=%s", rawType, len(payload), meta)
|
||||
if payload == "" || rawType == "" {
|
||||
return nil, fmt.Errorf("payload 和 type 参数不能为空")
|
||||
}
|
||||
@ -1017,20 +1398,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
||||
}
|
||||
return p.napcat("send_private_msg", msg)
|
||||
|
||||
case "image":
|
||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)}
|
||||
if groupID != 0 {
|
||||
msg["group_id"] = groupID
|
||||
} else {
|
||||
msg["user_id"] = userID
|
||||
case "image", "file":
|
||||
// 收敛到 output 通道:payload 支持本地路径或 http(s) URL。
|
||||
// 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式),
|
||||
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
|
||||
uri := payload
|
||||
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 {
|
||||
return p.napcat("send_group_msg", msg)
|
||||
cqTag := "file"
|
||||
if rawType == "image" {
|
||||
cqTag = "image"
|
||||
}
|
||||
return p.napcat("send_private_msg", msg)
|
||||
|
||||
case "file":
|
||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
|
||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
|
||||
if groupID != 0 {
|
||||
msg["group_id"] = groupID
|
||||
} else {
|
||||
@ -1108,7 +1501,7 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
|
||||
if name == "" {
|
||||
name = filepath.Base(filePath)
|
||||
}
|
||||
name = p.sensitiveFilter(name)
|
||||
name = sanitizeFilename(name)
|
||||
asImage, _ := args["as_image"].(bool)
|
||||
|
||||
// copy to remote dir for NapCat container access
|
||||
@ -1138,6 +1531,31 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
|
||||
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) {
|
||||
gid, gerr := convInt64(args["group_id"])
|
||||
uid, uerr := convInt64(args["user_id"])
|
||||
@ -1276,6 +1694,12 @@ func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, err
|
||||
if len(files) > 0 {
|
||||
result["files"] = files
|
||||
}
|
||||
// 拉取过某会话历史即视为已读(与真人客户端一致:看过=已读)
|
||||
if gerr == nil {
|
||||
p.markChatRead(gid)
|
||||
} else if uerr == nil {
|
||||
p.markChatRead(uid)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -1312,7 +1736,7 @@ func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{}
|
||||
}
|
||||
keyword = strings.ToLower(keyword)
|
||||
|
||||
gid, groupErr := convInt64(args["group_id"])
|
||||
gid, groupErr := convInt64(args["group_id"])
|
||||
if groupErr == nil {
|
||||
v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid})
|
||||
if err != nil {
|
||||
@ -1526,20 +1950,11 @@ func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, e
|
||||
// delete friend
|
||||
p.napcat("delete_friend", map[string]interface{}{"user_id": uid})
|
||||
// kick from groups
|
||||
if gid, err := convInt64(args["group_id"]); err == nil {
|
||||
p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true})
|
||||
} else {
|
||||
grps, _ := p.napcat("get_group_list", map[string]interface{}{})
|
||||
if list, ok := grps.([]interface{}); ok {
|
||||
for _, g := range list {
|
||||
if m, ok := g.(map[string]interface{}); ok {
|
||||
if gid, ok := m["group_id"].(float64); ok {
|
||||
p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gid, err := convInt64(args["group_id"])
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "block 必须提供 group_id(插件不会自动遍历所有群踢人)"}, nil
|
||||
}
|
||||
p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true})
|
||||
return `{"status":"ok","message":"blocked"}`, nil
|
||||
case "approve-friend":
|
||||
flag, _ := args["flag"].(string)
|
||||
@ -1574,6 +1989,7 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("group_file_%s", fileID)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
// get download URL
|
||||
resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID})
|
||||
if err != nil {
|
||||
@ -1593,7 +2009,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
|
||||
return resp, nil
|
||||
}
|
||||
dlURL := parsed.Data.URL
|
||||
httpResp, err := http.Get(dlURL)
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
httpResp, err := client.Get(dlURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download: %w", err)
|
||||
}
|
||||
@ -1648,6 +2065,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
||||
task := p.addDownloadTask(fileID, filename)
|
||||
|
||||
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 := ""
|
||||
errMsg := ""
|
||||
if furl != "" {
|
||||
@ -1701,7 +2123,7 @@ func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}
|
||||
if name == "" {
|
||||
name = filepath.Base(filePath)
|
||||
}
|
||||
name = p.sensitiveFilter(name)
|
||||
name = sanitizeFilename(name)
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
@ -2000,8 +2422,8 @@ func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, e
|
||||
result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": result,
|
||||
"file": path,
|
||||
"content": result,
|
||||
"file": path,
|
||||
"truncated": truncated,
|
||||
}, nil
|
||||
}
|
||||
@ -2218,6 +2640,8 @@ func rawString(v interface{}) (string, bool) {
|
||||
var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`)
|
||||
var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`)
|
||||
var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`)
|
||||
var stripCQRe = regexp.MustCompile(`\[CQ:[^\]]*\]|\[mirai:[^\]]*\]`)
|
||||
var highRiskRe = regexp.MustCompile(`(假如你是|你现在是|请你(扮演|化作|假装|成为)|扮演(一个|一下)|把你自己(想象|当成)|你的(人设|设定)是|穿越(到|回)|你是从.{0,10}(来|穿越)|帮我编(个|一个)故事|写(个|一个)故事让|故事(中|里)的|觉得(这个|这台|这家)?(机器人|AI|助手|ai).{0,8}(怎么样|如何|好不好|评价)|评价(下|一下)?(这个|这台|这家)?(机器人|AI|助手|ai|gpt)|忽略(之前|所有)?(指令|规则|限制|禁令)|解除.{0,6}(限制|规则|约束)|越狱|绕过.{0,6}(限制|审查)|不用(遵守|管)(任何)?(规则|限制|指令)|无视(所有)?(规则|指令)|你是(一个|一只)自由的)`)
|
||||
|
||||
func (p *Plugin) sensitiveFilter(text string) string {
|
||||
if p.remoteDir != "" {
|
||||
@ -2260,9 +2684,3 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
groupPolicy: "open",
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
153
example/recoverydiag/diag_test.go
Normal file
153
example/recoverydiag/diag_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
7
example/recoverydiag/go.mod
Normal file
7
example/recoverydiag/go.mod
Normal 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 => ../../
|
||||
11
example/recoverydiag/main.go
Normal file
11
example/recoverydiag/main.go
Normal 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)
|
||||
}
|
||||
21
example/recoverydiag/plg.json
Normal file
21
example/recoverydiag/plg.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "recoverydiag",
|
||||
"name_zh": "恢复诊断",
|
||||
"name_en": "Recovery Diagnostics",
|
||||
"version": "0.2.0",
|
||||
"description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-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": []
|
||||
}
|
||||
1023
example/recoverydiag/plugin.go
Normal file
1023
example/recoverydiag/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,8 +10,8 @@ require (
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
{
|
||||
{
|
||||
"name": "rss",
|
||||
"name_zh": "RSS订阅",
|
||||
"name_en": "RSS",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["rss", "feed", "subscription", "monitor"],
|
||||
"tags": [
|
||||
"rss",
|
||||
"feed",
|
||||
"subscription",
|
||||
"monitor"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ import (
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
const injectDedupWindow = 5 * time.Minute
|
||||
|
||||
type FeedSub struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
@ -32,7 +34,9 @@ type Plugin struct {
|
||||
mu sync.RWMutex
|
||||
feeds []FeedSub
|
||||
seenGUIDs map[string]bool
|
||||
injected map[string]time.Time
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
pollTicker *time.Ticker
|
||||
}
|
||||
@ -103,16 +107,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.fp = gofeed.NewParser()
|
||||
p.stopCh = make(chan struct{})
|
||||
p.seenGUIDs = make(map[string]bool)
|
||||
p.injected = make(map[string]time.Time)
|
||||
p.feeds = []FeedSub{}
|
||||
|
||||
dataHome := os.Getenv("HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = "/tmp"
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
}
|
||||
p.dataDir = filepath.Join(dataHome, ".homeagent", "rss")
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
p.loadData()
|
||||
|
||||
// 卸载(删除)时清理订阅数据目录;重载不触发
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "poll_interval", Default: "30", Type: "string",
|
||||
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
|
||||
@ -176,7 +186,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.stopOnce.Do(func() { close(p.stopCh) })
|
||||
p.pollTicker.Stop()
|
||||
p.wg.Wait()
|
||||
p.saveData()
|
||||
@ -248,9 +258,34 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(newArticles)))
|
||||
now := time.Now()
|
||||
toInject := make([]*gofeed.Item, 0, len(newArticles))
|
||||
p.mu.Lock()
|
||||
for _, item := range newArticles {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
key := sub.URL + "|" + guid
|
||||
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
|
||||
continue
|
||||
}
|
||||
p.injected[key] = now
|
||||
p.seenGUIDs[key] = true
|
||||
toInject = append(toInject, item)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if len(toInject) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
|
||||
for _, item := range toInject {
|
||||
pubDate := ""
|
||||
if item.PublishedParsed != nil {
|
||||
pubDate = item.PublishedParsed.Format("01-02 15:04")
|
||||
@ -266,19 +301,6 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
||||
}
|
||||
|
||||
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
||||
|
||||
p.mu.Lock()
|
||||
for _, item := range newArticles {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
p.seenGUIDs[sub.URL+"|"+guid] = true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.saveData()
|
||||
}
|
||||
|
||||
@ -320,6 +342,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro
|
||||
}
|
||||
|
||||
guidCount := 0
|
||||
p.mu.Lock()
|
||||
for _, item := range parsed.Items {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
@ -331,6 +354,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro
|
||||
p.seenGUIDs[url+"|"+guid] = true
|
||||
guidCount++
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
p.feeds = append(p.feeds, sub)
|
||||
@ -395,7 +419,16 @@ func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
|
||||
go p.checkAllFeeds()
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
|
||||
default:
|
||||
}
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
p.checkAllFeeds()
|
||||
}()
|
||||
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
|
||||
}
|
||||
|
||||
@ -434,7 +467,31 @@ func (p *Plugin) saveData() {
|
||||
SeenGUIDs: p.seenGUIDs,
|
||||
}
|
||||
b, _ := json.MarshalIndent(data, "", " ")
|
||||
os.WriteFile(p.dataFile(), b, 0644)
|
||||
atomicWriteJSON(p.dataFile(), b)
|
||||
}
|
||||
|
||||
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
||||
func (p *Plugin) cleanupData() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.dataDir == "" {
|
||||
return
|
||||
}
|
||||
for _, f := range []string{"feeds.json"} {
|
||||
path := filepath.Join(p.dataDir, f)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "sanitizer",
|
||||
"name_zh": "输出清洗",
|
||||
"name_en": "sanitizer",
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
|
||||
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
// 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
|
||||
// 同时保留原有"工具调用残留(思维泄漏)"清理。
|
||||
//
|
||||
// 挂载阶段:
|
||||
// - StageOnInput : 清洗用户输入(RawMessage)
|
||||
// - StageAfterToolcall : 清洗工具执行结果(ToolResults),坏字节不进 LLM 上下文
|
||||
// - StagePostAction : 清洗 LLM 输出(LLMText),保留原有思维泄漏清理
|
||||
//
|
||||
// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
|
||||
//
|
||||
// 编译:
|
||||
//
|
||||
@ -13,21 +21,24 @@ import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[::][^}]*\}\}`)
|
||||
atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`)
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
@ -36,10 +47,41 @@ func (p *Plugin) Name() string { return "sanitizer" }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
// 1) 输入清洗
|
||||
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := ctx.RawMessage
|
||||
ctx.RawMessage = cleanText(ctx.RawMessage)
|
||||
if before != ctx.RawMessage {
|
||||
log.Printf("[sanitizer] StageOnInput: cleaned %d bytes", len(before)-len(ctx.RawMessage))
|
||||
}
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 2) 工具结果清洗(坏字节/ANSI 不得进 LLM 上下文)
|
||||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
for i, tr := range ctx.ToolResults {
|
||||
if s, ok := tr.Result.(string); ok {
|
||||
clean := cleanText(s)
|
||||
if clean != s {
|
||||
ctx.ToolResults[i].Result = clean
|
||||
log.Printf("[sanitizer] StageAfterToolcall: tool=%s cleaned %d bytes", tr.Name, len(s)-len(clean))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 3) LLM 输出清洗(保留原有思维泄漏清理 + 新增乱码清洗)
|
||||
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := len(ctx.LLMText)
|
||||
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
|
||||
ctx.LLMText = cleanText(ctx.LLMText)
|
||||
after := len(ctx.LLMText)
|
||||
ctx.Unlock()
|
||||
if before != after {
|
||||
@ -47,7 +89,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
log.Printf("[sanitizer] stage PostAction registered")
|
||||
log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -57,6 +99,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
return &Plugin{}, nil
|
||||
}
|
||||
|
||||
// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
func cleanToolCallLeakage(content string) string {
|
||||
if content == "" {
|
||||
return content
|
||||
@ -83,8 +126,12 @@ func cleanToolCallLeakage(content string) string {
|
||||
cleaned = append(cleaned, line)
|
||||
continue
|
||||
}
|
||||
if toolNameRE.MatchString(trimmed) {
|
||||
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") {
|
||||
if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) {
|
||||
continue
|
||||
}
|
||||
if m := toolNameRE.FindStringIndex(trimmed); m != nil {
|
||||
rest := trimmed[m[1]:]
|
||||
if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -100,3 +147,72 @@ func cleanToolCallLeakage(content string) string {
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// cleanText 清洗可能污染 LLM 上下文/输出的文本:
|
||||
// 1. 剥离 ANSI 转义序列(\x1b[...m 等,源自终端输出)
|
||||
// 2. 剔除无效 UTF-8 字节(strings.ToValidUTF8 语义)与已解码的 U+FFFD 替换符,
|
||||
// 避免模型复读坏字节/替换符造成乱码(把坏段落整体丢弃比留残字更干净)
|
||||
func cleanText(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// 先剥离 ANSI 转义:ESC [ 参数 m / ESC ] 标题 / 其他 CSI 序列
|
||||
if strings.ContainsRune(s, 0x1b) {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
if c == 0x1b {
|
||||
// 跳过完整转义序列
|
||||
j := i + 1
|
||||
if j < len(s) {
|
||||
switch s[j] {
|
||||
case '[': // CSI: ESC [ <params> <letter>
|
||||
j++
|
||||
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
case ']': // OSC: ESC ] ... BEL / ST
|
||||
i = j + 1
|
||||
for i < len(s) && s[i] != 0x07 {
|
||||
i++
|
||||
}
|
||||
i++ // skip BEL
|
||||
continue
|
||||
default: // 单字符转义(ESC c ESC 7 等)
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
s = sb.String()
|
||||
}
|
||||
|
||||
// 剔除无效 UTF-8 与 U+FFFD 替换符
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
}
|
||||
if strings.ContainsRune(s, utf8.RuneError) {
|
||||
// 连 U+FFFD 也不留给模型复述
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r != utf8.RuneError {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s = b.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -2,6 +2,31 @@ package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"clean", "你好世界 hello", "你好世界 hello"},
|
||||
{"invalid_utf8", "a\xff\xfe b", "a b"},
|
||||
{"ufffd", "有乱码\ufffd字符", "有乱码字符"},
|
||||
{"multiple_ufffd", "a\ufffd\ufffdb\ufffdc", "abc"},
|
||||
{"ansi_color", "\x1b[31m红色\x1b[0m结束", "红色结束"},
|
||||
{"ansi_cursor", "a\x1b[2K\r\nb", "a\r\nb"},
|
||||
{"ansi_osc", "\x1b]0;title\x07文本", "文本"},
|
||||
{"an_and_ufffd", "\x1b[31m\ufffd中文\x1b[0m", "中文"},
|
||||
{"emoji_kept", "颜文字(・ω・´)和🍎", "颜文字(・ω・´)和🍎"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cleanText(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanToolCallLeakage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
@ -28,4 +53,4 @@ func TestCleanToolCallLeakage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
7
example/vanblog/go.mod
Normal file
7
example/vanblog/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module vanblog-plugin
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/vanblog/plg.json
Normal file
11
example/vanblog/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "vanblog",
|
||||
"name_zh": "VanBlog 博客管理",
|
||||
"name_en": "VanBlog",
|
||||
"version": "1.0.0",
|
||||
"description": "管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["blog", "vanblog", "cms"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
1334
example/vanblog/plugin.go
Normal file
1334
example/vanblog/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,5 +4,5 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
|
||||
@ -5,8 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -44,12 +42,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
}
|
||||
|
||||
dataHome := os.Getenv("HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = "/tmp"
|
||||
}
|
||||
os.MkdirAll(filepath.Join(dataHome, ".homeagent", "weather"), 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"current", sdk.ToolDef{
|
||||
Name: tp + "current", Description: "Get current weather for a city",
|
||||
@ -60,6 +52,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"},
|
||||
},
|
||||
},
|
||||
// NoMemory: 外部实时数据对记忆计算无长期价值,跳过向量化/关键词提取
|
||||
NoMemory: true,
|
||||
// Cleaner: 工具输出参与记忆计算前先过滤;这里演示用法(保留摘要行)
|
||||
Cleaner: func(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.HasPrefix(line, "🌤") {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleCurrent)
|
||||
|
||||
s.RegisterTool(tp+"forecast", sdk.ToolDef{
|
||||
@ -72,6 +75,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"},
|
||||
},
|
||||
},
|
||||
NoMemory: true,
|
||||
}, p.handleForecast)
|
||||
|
||||
s.RegisterTool(tp+"set_location", sdk.ToolDef{
|
||||
@ -83,8 +87,34 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
"required": []string{"location"},
|
||||
},
|
||||
NoMemory: true,
|
||||
}, p.handleSetLocation)
|
||||
|
||||
// 阶段钩子:own_tools 作用域——仅在本插件的工具被调用时触发
|
||||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
if len(ctx.ToolResults) > 0 {
|
||||
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
|
||||
}
|
||||
return nil
|
||||
}, sdk.StageScopeOwnTools)
|
||||
|
||||
// 输出通道:把天气结果主动推给用户(如 QQ/WebUI 渠道)
|
||||
if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{
|
||||
NoMemory: true,
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
return map[string]interface{}{"content": "weather pushed: " + payload}, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 输入通道:接收天气订阅请求(NoMemory: 通道输入不参与记忆计算)
|
||||
if err := s.RegisterInputChannel(tp+"weather_in", sdk.ChannelDef{NoMemory: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] started\n", p.name)
|
||||
return nil
|
||||
}
|
||||
@ -343,7 +373,11 @@ func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error
|
||||
sunset = day.Astronomy[0].Sunset
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(" %s %s/%s — %s~%s%s %s", weekday, day.Date[5:], day.Date[8:], minT, maxT, unitStr, desc)
|
||||
datePart := ""
|
||||
if len(day.Date) >= 8 {
|
||||
datePart = day.Date[5:7] + "/" + day.Date[8:]
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s — %s~%s%s %s", weekday, datePart, minT, maxT, unitStr, desc)
|
||||
if precip != "" {
|
||||
line += precip
|
||||
}
|
||||
|
||||
80
meta/meta.go
80
meta/meta.go
@ -1,12 +1,15 @@
|
||||
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
||||
// 版本号应与核心 meta.Version 保持一致。
|
||||
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
|
||||
package meta
|
||||
|
||||
var (
|
||||
// Version 是 HomeAgent SDK 版本号。
|
||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||
Version = "0.8.0"
|
||||
//
|
||||
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
|
||||
// 公开 SDK 接口(sdk/ 目录)**零改动**——插件业务代码不需要改一行,
|
||||
// 但产物形态变了(plugin.so → plugin.bin),必须用新版 plugindev 重编。
|
||||
Version = "1.0.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -21,7 +24,10 @@ var (
|
||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||
|
||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||
CoreVersion = "0.8.0"
|
||||
//
|
||||
// 1.0.0 是硬下限而非建议值:0.9.x 内核只会 dlopen `.so`,
|
||||
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
|
||||
CoreVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
@ -29,59 +35,15 @@ func FullVersion() string {
|
||||
return SDKName + " v" + Version + " (" + Commit + ")"
|
||||
}
|
||||
|
||||
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
|
||||
// 修改时需确保核心仓与 SDK 仓的值一致。
|
||||
|
||||
const (
|
||||
ABIVersion = 1
|
||||
ABIVersionMin = 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
|
||||
)
|
||||
// ---- 协议版本 ----
|
||||
//
|
||||
// 子进程 RPC 的协议版本是一个独立的小整数,与 SDK/内核语义版本解耦:
|
||||
// 语义版本变动频繁(修 bug、加字段),而 wire 协议只在**帧格式或握手语义**
|
||||
// 变化时才升。当前值见核心仓 internal/plugin/proc/protocol.go 的 ProtocolVersion。
|
||||
//
|
||||
// C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
|
||||
// Part 6.2 删除 internal/plugin/cabi/ 一并退场:
|
||||
// - 整数 method id 平移为 method 名字符串(proc/protocol.go 的 Method* 常量)
|
||||
// - 版本协商改为握手帧里的 protocol 字段
|
||||
//
|
||||
// 保留那些常量只会让人以为它们还在生效。
|
||||
|
||||
116
remotedevice/CMakeLists.txt
Normal file
116
remotedevice/CMakeLists.txt
Normal 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}")
|
||||
216
remotedevice/include/ha_remotedevice.h
Normal file
216
remotedevice/include/ha_remotedevice.h
Normal 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 的字节数(不含 \0),out 不足时返回所需长度。 */
|
||||
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
369
remotedevice/src/ha_json.c
Normal 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
107
remotedevice/src/ha_json.h
Normal 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 */
|
||||
628
remotedevice/src/ha_remotedevice.c
Normal file
628
remotedevice/src/ha_remotedevice.c
Normal 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
325
remotedevice/src/ha_ws.c
Normal 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
62
remotedevice/src/ha_ws.h
Normal 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 */
|
||||
1509
remotedevice/test/test_ha_remotedevice.c
Normal file
1509
remotedevice/test/test_ha_remotedevice.c
Normal file
File diff suppressed because it is too large
Load Diff
115
sdk/plugin.go
115
sdk/plugin.go
@ -111,6 +111,12 @@ type IOInjector interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||
InjectInputSync(source, channel, text string) string
|
||||
// SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||
SetToolBlocks(blocks []ContentBlock)
|
||||
}
|
||||
|
||||
// EventType identifies the kind of system event.
|
||||
@ -124,6 +130,11 @@ const (
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
EventSystem EventType = "system"
|
||||
|
||||
// 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。
|
||||
// 客户端可选订做真逐 token 渲染;聚合事件仍照常发布,旧订阅者不受影响。
|
||||
EventReasoningDelta EventType = "reasoning_delta"
|
||||
EventContentDelta EventType = "content_delta"
|
||||
)
|
||||
|
||||
// Event represents a system event published by the kernel.
|
||||
@ -144,6 +155,17 @@ type EventSubscriber interface {
|
||||
Subscribe(eventType EventType, handler EventHandler) func()
|
||||
}
|
||||
|
||||
// PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||
// 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||
type PluginMgrAPI interface {
|
||||
// ReloadOne 重载单个插件(停止后重新加载)。
|
||||
ReloadOne(name string) error
|
||||
// ListLoadedPlugins 列出已加载插件。
|
||||
ListLoadedPlugins() []string
|
||||
// IsPluginDisabled 查询插件是否被禁用。
|
||||
IsPluginDisabled(name string) bool
|
||||
}
|
||||
|
||||
// StageScope controls which events a stage handler receives.
|
||||
type StageScope int
|
||||
|
||||
@ -197,8 +219,15 @@ type PluginSDK struct {
|
||||
sett SettingsAPI
|
||||
social SocialAPI
|
||||
events EventSubscriber
|
||||
plgMgr PluginMgrAPI
|
||||
|
||||
autoRestart bool
|
||||
|
||||
stopMu sync.Mutex
|
||||
stopHandlers []func()
|
||||
|
||||
removeMu sync.Mutex
|
||||
removeHandlers []func()
|
||||
}
|
||||
|
||||
// New creates a PluginSDK with the given dependencies.
|
||||
@ -338,6 +367,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
||||
|
||||
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
|
||||
|
||||
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||
// May be nil if the host did not wire it.
|
||||
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
|
||||
|
||||
// ---- IO Convenience Methods ----
|
||||
|
||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||
@ -361,9 +397,88 @@ func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||
// returning the reply text (empty string if none). Replies must be dispatched back
|
||||
// to the source channel by the caller.
|
||||
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
||||
if s.io == nil {
|
||||
return ""
|
||||
}
|
||||
return s.io.InjectInputSync(source, channel, text)
|
||||
}
|
||||
|
||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
|
||||
|
||||
// AutoRestart 返回插件是否允许自动重启。
|
||||
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
|
||||
|
||||
// RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||
// 适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
|
||||
// 可注册多个;执行后清空(进程停止前只执行一次)。
|
||||
func (s *PluginSDK) RegisterStopHandler(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.stopMu.Lock()
|
||||
s.stopHandlers = append(s.stopHandlers, fn)
|
||||
s.stopMu.Unlock()
|
||||
}
|
||||
|
||||
// RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
|
||||
// 由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
|
||||
func (s *PluginSDK) RunStopHandlers() {
|
||||
s.stopMu.Lock()
|
||||
handlers := append([]func(){}, s.stopHandlers...)
|
||||
s.stopHandlers = nil
|
||||
s.stopMu.Unlock()
|
||||
for i := len(handlers) - 1; i >= 0; i-- {
|
||||
handlers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
|
||||
// 注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
|
||||
// 适用于清理外部资源、删除配置表、下线状态等删除后处理。
|
||||
// 可注册多个;执行后清空(一次删除只执行一次)。
|
||||
func (s *PluginSDK) RegisterOnRemoveHandler(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.removeMu.Lock()
|
||||
s.removeHandlers = append(s.removeHandlers, fn)
|
||||
s.removeMu.Unlock()
|
||||
}
|
||||
|
||||
// RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
|
||||
// 由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
|
||||
func (s *PluginSDK) RunOnRemoveHandlers() {
|
||||
s.removeMu.Lock()
|
||||
handlers := append([]func(){}, s.removeHandlers...)
|
||||
s.removeHandlers = nil
|
||||
s.removeMu.Unlock()
|
||||
for i := len(handlers) - 1; i >= 0; i-- {
|
||||
handlers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@ -19,6 +19,11 @@ type SettingsAPI interface {
|
||||
// ListCore lists core config keys matching the prefix.
|
||||
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(plugin, key string) (interface{}, error)
|
||||
|
||||
|
||||
@ -24,7 +24,8 @@ func cmdBuild(args []string) {
|
||||
// Read all config from plg.json first
|
||||
plg, err := readPlgJSON("plg.json")
|
||||
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
|
||||
@ -39,11 +40,13 @@ func cmdBuild(args []string) {
|
||||
switch args[i] {
|
||||
case "--outdir":
|
||||
if i+1 < len(args) {
|
||||
outDir = args[i+1]; i++
|
||||
outDir = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--target":
|
||||
if i+1 < len(args) {
|
||||
targets = append(targets, args[i+1]); i++
|
||||
targets = append(targets, args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--bundle":
|
||||
bundle = true
|
||||
@ -51,11 +54,13 @@ func cmdBuild(args []string) {
|
||||
bundle = false
|
||||
case "--sdk-path":
|
||||
if i+1 < len(args) {
|
||||
sdkPath = args[i+1]; i++
|
||||
sdkPath = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--replace", "-R":
|
||||
if i+1 < len(args) {
|
||||
cliReplaces = append(cliReplaces, args[i+1]); i++
|
||||
cliReplaces = append(cliReplaces, args[i+1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,7 +71,12 @@ func cmdBuild(args []string) {
|
||||
}
|
||||
|
||||
// Ensure go.mod exists with correct SDK path
|
||||
ensureGoMod(plg, sdkPath)
|
||||
sdkModule := ensureGoMod(plg, sdkPath)
|
||||
|
||||
// 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
|
||||
if sdkModule != "" {
|
||||
ensureSDKResolvable(plg, sdkModule, sdkPath)
|
||||
}
|
||||
|
||||
// Merge plg.json replaces + CLI overrides
|
||||
replaceSlice := plg.ReplacesToSlice()
|
||||
@ -91,14 +101,17 @@ func cmdBuild(args []string) {
|
||||
}
|
||||
|
||||
// allBundleTargets 是 --bundle 模式构建的全部平台。
|
||||
// 每个 OS 只有一个架构(amd64),避免二进制文件名冲突。
|
||||
//
|
||||
// 子进程模式下各平台产物同名(plugin.bin)——进程边界即 ABI 边界,
|
||||
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
|
||||
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
|
||||
var allBundleTargets = []struct {
|
||||
target string
|
||||
entry string // 二进制在 zip 中的文件名
|
||||
}{
|
||||
{"linux/amd64", "plugin.so"},
|
||||
{"darwin/amd64", "plugin.dylib"},
|
||||
{"windows/amd64", "plugin.dll"},
|
||||
{"linux/amd64", "plugin.bin.linux.amd64"},
|
||||
{"darwin/amd64", "plugin.bin.darwin.amd64"},
|
||||
{"windows/amd64", "plugin.bin.windows.amd64"},
|
||||
}
|
||||
|
||||
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||
@ -106,9 +119,13 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||
buildDir := "build"
|
||||
os.MkdirAll(buildDir, 0755)
|
||||
|
||||
// Auto-generate C ABI bridge for non-Windows
|
||||
bridgeCleanup := generateBridge("")
|
||||
defer bridgeCleanup()
|
||||
runtimeCleanup, err := generateProcRuntime()
|
||||
if err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer runtimeCleanup()
|
||||
|
||||
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
|
||||
defer thirdpartCleanup()
|
||||
|
||||
@ -121,22 +138,18 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||
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 = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
|
||||
|
||||
if cfg.goos == "windows" {
|
||||
cc := detectWindowsCC()
|
||||
if cc != "" {
|
||||
cmd.Env = append(cmd.Env, "CC="+cc)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
|
||||
cmd.Stdout = os.Stdout
|
||||
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 {
|
||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||
return
|
||||
@ -154,7 +167,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||
for p := range platforms {
|
||||
plats = append(plats, p)
|
||||
}
|
||||
writePluginJSON(plg, plats, "plugin.so")
|
||||
writePluginJSON(plg, plats, procEntryFile)
|
||||
|
||||
// package single .hmap with correctly named entries
|
||||
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
|
||||
@ -162,7 +175,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||
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) {
|
||||
data, err := os.ReadFile(path)
|
||||
@ -214,9 +230,14 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
|
||||
type buildConfig struct {
|
||||
goos 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.2:Windows 不再是能力退化的第三套实现)。
|
||||
func resolveBuild(target string) (*buildConfig, string) {
|
||||
if target == "lua" || target == "" {
|
||||
return nil, "lua"
|
||||
@ -231,14 +252,8 @@ func resolveBuild(target string) (*buildConfig, string) {
|
||||
}
|
||||
|
||||
switch goos {
|
||||
case "linux":
|
||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, ""
|
||||
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"}, ""
|
||||
case "linux", "darwin", "freebsd", "windows":
|
||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unsupported OS %q", goos)
|
||||
}
|
||||
@ -246,42 +261,49 @@ func resolveBuild(target string) (*buildConfig, string) {
|
||||
|
||||
// ensureGoMod 确保插件项目的 go.mod 包含 SDK 的 replace 指令。
|
||||
// 如果 go.mod 不存在或已有正确 replace,则跳过。
|
||||
func ensureGoMod(plg *PlgConfig, sdkPath string) {
|
||||
if sdkPath == "" {
|
||||
// 从 plugindev 自身推断 SDK 路径
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
|
||||
if _, err := os.Stat(filepath.Join(cand, "sdk", "plugin.go")); err != nil {
|
||||
return
|
||||
}
|
||||
sdkPath = cand
|
||||
}
|
||||
|
||||
func ensureGoMod(plg *PlgConfig, sdkPath string) string {
|
||||
gomodPath := "go.mod"
|
||||
data, err := os.ReadFile(gomodPath)
|
||||
if err != nil {
|
||||
return // no go.mod, skip
|
||||
return "" // no go.mod, skip
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
var sdkModule string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "require ") || strings.HasPrefix(line, "require (") {
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "homeagent-sdk/sdk") || strings.Contains(line, "homeagent-sdk") {
|
||||
var mod string
|
||||
if strings.HasPrefix(line, "require ") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 1 && !strings.HasPrefix(parts[0], "//") && !strings.HasPrefix(parts[0], "replace") {
|
||||
sdkModule = parts[0]
|
||||
if len(parts) >= 2 {
|
||||
mod = parts[1]
|
||||
}
|
||||
} else if !strings.HasPrefix(line, "require") &&
|
||||
!strings.HasPrefix(line, "module ") &&
|
||||
!strings.HasPrefix(line, "go ") &&
|
||||
!strings.HasPrefix(line, "replace ") {
|
||||
// require 块内行(无前缀)或 import 行
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 1 {
|
||||
mod = parts[0]
|
||||
}
|
||||
}
|
||||
if mod != "" && strings.Contains(mod, "homeagent-sdk") {
|
||||
sdkModule = mod
|
||||
break
|
||||
}
|
||||
}
|
||||
if sdkModule == "" {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
if sdkPath == "" {
|
||||
// 仅显式配置(plg.json sdk_path 或 --sdk-path)才写入 replace,
|
||||
// 避免 go.mod 中出现本地绝对路径。
|
||||
return sdkModule
|
||||
}
|
||||
|
||||
absSDK, _ := filepath.Abs(sdkPath)
|
||||
@ -303,12 +325,135 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) {
|
||||
keep = append(keep, line)
|
||||
}
|
||||
if alreadyExists {
|
||||
return
|
||||
return sdkModule
|
||||
}
|
||||
keep = append(keep, replaceLine, "")
|
||||
if err := os.WriteFile(gomodPath, []byte(strings.Join(keep, "\n")), 0644); err != nil {
|
||||
fmt.Printf(" warn: update go.mod replace: %v\n", err)
|
||||
}
|
||||
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 {
|
||||
@ -381,7 +526,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve build config
|
||||
// Resolve build config(全平台统一产出 plugin.bin)
|
||||
cfg, errMsg := resolveBuild(target)
|
||||
if cfg == nil {
|
||||
fmt.Printf(" error: %s\n", errMsg)
|
||||
@ -392,9 +537,12 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
||||
os.MkdirAll(buildDir, 0755)
|
||||
outPath := filepath.Join(buildDir, cfg.entryFile)
|
||||
|
||||
// Auto-generate C ABI bridge (all platforms use c-shared)
|
||||
bridgeCleanup := generateBridge(cfg.goos)
|
||||
defer bridgeCleanup()
|
||||
runtimeCleanup, err := generateProcRuntime()
|
||||
if err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer runtimeCleanup()
|
||||
|
||||
// Auto-link thirdpart/ contents + source_dirs + replace targets
|
||||
thirdpartCleanup := linkThirdpart(plg, target)
|
||||
@ -403,28 +551,15 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
||||
// Write plugin.json with the correct entry for this target
|
||||
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 = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
|
||||
|
||||
// Auto-detect MinGW gcc on Windows
|
||||
if cfg.goos == "windows" {
|
||||
cc := detectWindowsCC()
|
||||
if cc != "" {
|
||||
cmd.Env = append(cmd.Env, "CC="+cc)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
// DEBUG: list files before building
|
||||
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)
|
||||
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||
return
|
||||
@ -443,8 +578,8 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
||||
}
|
||||
|
||||
type binEntry struct {
|
||||
src string // 磁盘路径,如 build/plugin.so
|
||||
zip string // zip 中条目名,如 plugin.so
|
||||
src string // 磁盘路径,如 build/plugin.bin
|
||||
zip string // zip 中条目名,如 plugin.bin.linux.amd64
|
||||
}
|
||||
|
||||
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
|
||||
@ -544,72 +679,8 @@ func toSnake(s string) string {
|
||||
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,
|
||||
// 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
|
||||
// for source files, generating auto-import stubs. Returns cleanup function.
|
||||
func linkThirdpart(plg *PlgConfig, target string) func() {
|
||||
|
||||
@ -172,4 +172,4 @@ func debugGo(dir string, replaces []string) {
|
||||
}
|
||||
}
|
||||
|
||||
var _ = strings.TrimSpace
|
||||
|
||||
|
||||
@ -7,8 +7,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||
)
|
||||
|
||||
func (p *PlgConfig) ReplacesToSlice() []string {
|
||||
@ -61,27 +59,57 @@ type TemplateData struct {
|
||||
GoVersion string
|
||||
SDKModule string
|
||||
SDKVersion string
|
||||
SDKReplace string
|
||||
|
||||
// C ABI
|
||||
CABIVersion int
|
||||
CABIHeader string
|
||||
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
|
||||
//
|
||||
// 为何必须写:gitcode 的模块不在 proxy.golang.org 上,只 require 一个
|
||||
// 版本号的 go.mod 配上缺失的 go.sum,新用户第一次 `plugindev build`
|
||||
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
|
||||
// 一个不存在的条目。有了本地 replace,go 完全不需要 go.sum 条目。
|
||||
SDKLocalPath string
|
||||
}
|
||||
|
||||
func cmdInit(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Println("Usage: plugindev init <name> [--lua]")
|
||||
fmt.Println("Usage: plugindev init <name> [--lua] [--type remotedevice]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
isLua := false
|
||||
isRemoteDevice := false
|
||||
for _, a := range args[1:] {
|
||||
switch a {
|
||||
case "--lua":
|
||||
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
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
@ -89,7 +117,12 @@ func cmdInit(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
entry := "plugin.so"
|
||||
// Go 插件统一产出 plugin.bin(v1.0.0 子进程模式)。
|
||||
//
|
||||
// 此前这里写 "plugin.so",scaffold 出来的 plg.json 就带着一个已退场的
|
||||
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
|
||||
// build 实际不看这个值(只用它区分 Lua),但模板不应误导。
|
||||
entry := "plugin.bin"
|
||||
var targets string
|
||||
if isLua {
|
||||
entry = "main.lua"
|
||||
@ -113,24 +146,19 @@ func cmdInit(args []string) {
|
||||
Tags: []string{name},
|
||||
Targets: targets,
|
||||
},
|
||||
IsLua: isLua,
|
||||
CABIVersion: meta.ABIVersion,
|
||||
CABIHeader: tmplCABIHeader,
|
||||
IsLua: isLua,
|
||||
}
|
||||
|
||||
// Detect SDK info for Go plugin go.mod
|
||||
// Detect SDK info for Go plugin go.mod.
|
||||
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace:
|
||||
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
|
||||
if !isLua {
|
||||
sdkMod, goVer, sdkPath, sdkVer := detectSDKInfo()
|
||||
sdkReplace := sdkPath
|
||||
// Make replace path absolute and use forward slashes
|
||||
if abs, err := filepath.Abs(sdkPath); err == nil {
|
||||
sdkReplace = strings.ReplaceAll(abs, "\\", "/")
|
||||
}
|
||||
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
|
||||
data.ModulePath = name
|
||||
data.GoVersion = goVer
|
||||
data.SDKModule = sdkMod
|
||||
data.SDKVersion = "v" + sdkVer
|
||||
data.SDKReplace = sdkReplace
|
||||
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@ -197,6 +225,57 @@ func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
|
||||
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) {
|
||||
tmpl, err := template.New("").Parse(content)
|
||||
if err != nil {
|
||||
|
||||
@ -158,34 +158,6 @@ func cmdSDKInstall(version string) {
|
||||
url := fmt.Sprintf(sdkDownloadURL, version, version)
|
||||
fmt.Printf("Downloading SDK %s from Release archive...\n", version)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "homeagent-sdk-*.tar.gz")
|
||||
if err != nil {
|
||||
fmt.Printf("error: create temp file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Printf("error: download SDK %s: %v\n", version, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("error: download SDK %s: HTTP %d\n", version, resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
fmt.Printf("error: save SDK archive: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Extract to temp dir, then rename to dest
|
||||
tmpDir, err := os.MkdirTemp("", "homeagent-sdk-extract-*")
|
||||
if err != nil {
|
||||
fmt.Printf("error: create temp dir: %v\n", err)
|
||||
@ -193,61 +165,13 @@ func cmdSDKInstall(version string) {
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
f, err := openFile(tmpPath)
|
||||
if err != nil {
|
||||
fmt.Printf("error: open archive: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
gzr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
fmt.Printf("error: read archive: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer gzr.Close()
|
||||
defer f.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("error: extract archive: %v\n", err)
|
||||
if err := installFromArchive(url, tmpDir); err != nil {
|
||||
fmt.Printf("warn: archive download failed (%v), falling back to git clone...\n", err)
|
||||
if err := installFromGit(version, tmpDir); err != nil {
|
||||
fmt.Printf("error: install SDK %s: %v\n", version, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Strip top-level directory from archive path
|
||||
parts := strings.SplitN(header.Name, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
relPath := parts[1]
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(tmpDir, relPath)
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(target, os.FileMode(header.Mode))
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
fmt.Printf("error: create file %s: %v\n", target, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
fmt.Printf("error: write file %s: %v\n", target, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
gzr.Close()
|
||||
|
||||
if err := os.Rename(tmpDir, dest); err != nil {
|
||||
// Cross-filesystem rename fallback
|
||||
@ -274,6 +198,94 @@ func openFile(path string) (*os.File, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// installFromArchive downloads the SDK release archive and extracts it to tmpDir.
|
||||
func installFromArchive(url, tmpDir string) error {
|
||||
tmpFile, err := os.CreateTemp("", "homeagent-sdk-*.tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download SDK %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
f, err := openFile(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gzr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
defer gzr.Close()
|
||||
defer f.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Strip top-level directory from archive path
|
||||
parts := strings.SplitN(header.Name, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
relPath := parts[1]
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(tmpDir, relPath)
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(target, os.FileMode(header.Mode))
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installFromGit clones the SDK repo at the given tag/branch into tmpDir.
|
||||
func installFromGit(version, tmpDir string) error {
|
||||
cmd := exec.Command("git", "clone", "--depth", "1", "--branch", version, sdkRepoURL, tmpDir)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git clone: %v", err)
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(tmpDir, ".git"))
|
||||
}
|
||||
|
||||
// cmdSDKUse switches the active SDK version.
|
||||
func cmdSDKUse(version string) {
|
||||
store := sdkStore()
|
||||
|
||||
@ -30,15 +30,20 @@ func help() {
|
||||
fmt.Print(`HomeAgent Plugin Dev Tool
|
||||
|
||||
Usage:
|
||||
plugindev init <name> Scaffold a new plugin project
|
||||
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
|
||||
plugindev init <name> Scaffold a new plugin project
|
||||
plugindev init <name> --lua Create Lua plugin
|
||||
plugindev init <name> --type remotedevice
|
||||
Create C remote device adapter
|
||||
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:
|
||||
--outdir Output directory (default: dist)
|
||||
--target Target OS/arch (e.g. linux/amd64), repeatable
|
||||
--lua Create Lua plugin (for init)
|
||||
--type Project type: "remotedevice" (for init)
|
||||
-t Alias for --type
|
||||
`)
|
||||
}
|
||||
|
||||
91
tools/plugindev/proc_runtime.go
Normal file
91
tools/plugindev/proc_runtime.go
Normal 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 值。
|
||||
//
|
||||
// 为何忽略 entry:17 个存量插件的 plg.json 都写着 "plugin.so"。若把 entry 当作
|
||||
// 通道开关,迁移就得改 17 个文件——而「外部插件零改动」是本次迁移的硬约束。
|
||||
// entry 现在只用于区分 Lua(main.lua)与 Go 插件。
|
||||
394
tools/plugindev/proc_runtime_test.go
Normal file
394
tools/plugindev/proc_runtime_test.go
Normal 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 不足时必须报错,不得静默截断")
|
||||
}
|
||||
}
|
||||
|
||||
// 日志必须走 stderr:stdout 是 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 cfg(Lua 不经 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 常量")
|
||||
}
|
||||
}
|
||||
247
tools/plugindev/stagediff_test.go
Normal file
247
tools/plugindev/stagediff_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 复刻现网场景(实验 13):sanitizer 清洗后 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 顺序应用 diff(weather 后到,是最坏情形)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
package main
|
||||
|
||||
// tmplPluginInitC is in templates.go (moved to keep all C ABI together)
|
||||
@ -19,9 +19,12 @@ const tmplGoMod = `module {{.ModulePath}}
|
||||
go {{.GoVersion}}
|
||||
|
||||
require {{.SDKModule}} {{.SDKVersion}}
|
||||
|
||||
replace {{.SDKModule}} => {{.SDKReplace}}
|
||||
`
|
||||
{{if .SDKLocalPath}}
|
||||
// SDK 指向本机源码。gitcode 的模块不在 proxy.golang.org 上,
|
||||
// 没有这条 replace 就需要 go.sum 条目,而那个条目无处可拉。
|
||||
// 若你已有可访问的私有 proxy,可删掉本行。
|
||||
replace {{.SDKModule}} => {{.SDKLocalPath}}
|
||||
{{end}}`
|
||||
|
||||
const tmplPluginGo = `package main
|
||||
|
||||
@ -39,6 +42,7 @@ func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) })
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
|
||||
DisplayName: "示例配置", Description: "An example configuration key",
|
||||
@ -153,633 +157,351 @@ function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end
|
||||
return plugin
|
||||
`
|
||||
|
||||
// tmplBridge — Windows DLL C ABI bridge (unchanged)
|
||||
const tmplBridge = `//go:build windows && cgo
|
||||
// ============================================================
|
||||
// Remote Device Adapter Templates
|
||||
// ============================================================
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
const tmplRemoteDeviceMain = `#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"unsafe"
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
handleMap = map[unsafe.Pointer]*bridgeState{}
|
||||
)
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
type bridgeState struct {
|
||||
plugin sdk.Plugin
|
||||
toolDefs map[string]sdk.ToolDef
|
||||
handlers map[string]sdk.ToolHandler
|
||||
stages map[string]sdk.StageHandler
|
||||
settings map[string]interface{}
|
||||
}
|
||||
/* ============================================================
|
||||
* {{.Plg.Name}} — Remote Device Adapter
|
||||
*
|
||||
* 声明式远程设备接入示例。
|
||||
* 用户只需实现:
|
||||
* 1. ha_transport_t 的 4 个函数
|
||||
* 2. 声明 handlers 表(设备支持哪些命令 + 对应的处理函数)
|
||||
* 其余协议细节(WS 握手、hello/bind、心跳、重连、命令分发、结果回执)由 SDK 自动处理。
|
||||
* ============================================================ */
|
||||
|
||||
func newHandle(plg sdk.Plugin) unsafe.Pointer {
|
||||
mu.Lock(); defer mu.Unlock()
|
||||
h := C.malloc(C.size_t(1))
|
||||
handleMap[h] = &bridgeState{
|
||||
plugin: plg, toolDefs: make(map[string]sdk.ToolDef),
|
||||
handlers: make(map[string]sdk.ToolHandler), stages: make(map[string]sdk.StageHandler),
|
||||
settings: make(map[string]interface{}),
|
||||
}
|
||||
return h
|
||||
}
|
||||
func getState(h unsafe.Pointer) *bridgeState { mu.Lock(); defer mu.Unlock(); return handleMap[h] }
|
||||
func delState(h unsafe.Pointer) { mu.Lock(); defer mu.Unlock(); delete(handleMap, h); C.free(h) }
|
||||
/* ====================== 传输层实现 ======================
|
||||
*
|
||||
* 请为你的平台实现以下 4 个函数:
|
||||
* connect(ctx, host, port) — 建立 TCP 连接
|
||||
* send(ctx, data, len) — 发送数据
|
||||
* recv(ctx, buf, len) — 接收数据(阻塞,返回实际接收字节数)
|
||||
* close(ctx) — 关闭连接
|
||||
*
|
||||
* 示例:POSIX socket 实现
|
||||
*/
|
||||
|
||||
//export NewPlugin
|
||||
func NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer {
|
||||
goName := C.GoString(name)
|
||||
var config map[string]interface{}
|
||||
if configJSON != nil {
|
||||
var wrapper map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(C.GoString(configJSON)), &wrapper); err == nil {
|
||||
if c, ok := wrapper["config"].(map[string]interface{}); ok { config = c }
|
||||
}
|
||||
}
|
||||
plg, err := NewPluginFactory(goName, config)
|
||||
if err != nil { return nil }
|
||||
return newHandle(plg)
|
||||
}
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
/* Windows 平台需包含 winsock2.h */
|
||||
#error "Please implement transport for your platform (see example below)"
|
||||
#else
|
||||
/* POSIX (Linux, macOS, ESP-IDF, Zephyr, etc.) */
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
|
||||
//export StartPlugin
|
||||
func StartPlugin(handle unsafe.Pointer) C.int {
|
||||
bs := getState(handle)
|
||||
if bs == nil { return 1 }
|
||||
mockSett := &bridgeSettings{data: bs.settings}
|
||||
mockSDK := sdk.New(bs.plugin.Name(), mockSett,
|
||||
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
bs.toolDefs[name] = def; bs.handlers[name] = handler; return nil
|
||||
},
|
||||
func(stage sdk.Stage, handler sdk.StageHandler) { bs.stages[string(stage)] = handler },
|
||||
func(name string) error { return nil },
|
||||
func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error { return nil },
|
||||
)
|
||||
mockSDK.SetInputChannelRegistrar(func(name string, def sdk.ChannelDef) error { return nil })
|
||||
if err := bs.plugin.Start(mockSDK); err != nil { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
//export StopPlugin
|
||||
func StopPlugin(handle unsafe.Pointer) C.int {
|
||||
bs := getState(handle)
|
||||
if bs == nil { return 1 }
|
||||
if err := bs.plugin.Stop(); err != nil { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
//export DestroyPlugin
|
||||
func DestroyPlugin(handle unsafe.Pointer) {
|
||||
if bs := getState(handle); bs != nil { delState(handle) }
|
||||
}
|
||||
|
||||
//export GetToolDefsJSON
|
||||
func GetToolDefsJSON(handle unsafe.Pointer) *C.char {
|
||||
bs := getState(handle)
|
||||
if bs == nil { return nil }
|
||||
defs := make([]sdk.ToolDef, 0, len(bs.toolDefs))
|
||||
for _, def := range bs.toolDefs { defs = append(defs, def) }
|
||||
b, _ := json.Marshal(defs)
|
||||
return C.CString(string(b))
|
||||
}
|
||||
|
||||
//export InvokeToolJSON
|
||||
func InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char {
|
||||
bs := getState(handle)
|
||||
if bs == nil || toolName == nil { return nil }
|
||||
goName := C.GoString(toolName)
|
||||
handler, ok := bs.handlers[goName]
|
||||
if !ok { errMsg, _ := json.Marshal(map[string]interface{}{"error": "tool not found: " + goName}); return C.CString(string(errMsg)) }
|
||||
var args map[string]interface{}
|
||||
if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) }
|
||||
r, err := handler(args)
|
||||
if err != nil { errMsg, _ := json.Marshal(map[string]interface{}{"error": err.Error()}); return C.CString(string(errMsg)) }
|
||||
b, _ := json.Marshal(r)
|
||||
return C.CString(string(b))
|
||||
}
|
||||
|
||||
//export GetStagesJSON
|
||||
func GetStagesJSON(handle unsafe.Pointer) *C.char {
|
||||
bs := getState(handle)
|
||||
if bs == nil { return nil }
|
||||
type se struct { Stage string ` + "`" + `json:"stage"` + "`" + ` }
|
||||
var entries []se
|
||||
for s := range bs.stages { entries = append(entries, se{s}) }
|
||||
b, _ := json.Marshal(entries)
|
||||
return C.CString(string(b))
|
||||
}
|
||||
|
||||
//export InvokeStage
|
||||
func InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int {
|
||||
bs := getState(handle)
|
||||
if bs == nil || stage == nil { return 1 }
|
||||
goStage := C.GoString(stage)
|
||||
handler, ok := bs.stages[goStage]
|
||||
if !ok { return 1 }
|
||||
var ctx map[string]interface{}
|
||||
if contextJSON != nil { json.Unmarshal([]byte(C.GoString(contextJSON)), &ctx) }
|
||||
sc := &sdk.StageContext{}
|
||||
if ctx != nil {
|
||||
if v, ok := ctx["raw_message"].(string); ok { sc.RawMessage = v }
|
||||
if v, ok := ctx["user_id"].(string); ok { sc.UserID = v }
|
||||
if v, ok := ctx["phase"].(string); ok { sc.Phase = sdk.Stage(v) }
|
||||
}
|
||||
if err := handler(sc); err != nil { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
//export FreeCString
|
||||
func FreeCString(s *C.char) { C.free(unsafe.Pointer(s)) }
|
||||
|
||||
type bridgeSettings struct{ data map[string]interface{} }
|
||||
func (s *bridgeSettings) Get(key string) (interface{}, error) { v, ok := s.data[key]; if !ok { return nil, nil }; return v, nil }
|
||||
func (s *bridgeSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
|
||||
func (s *bridgeSettings) List(prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range s.data { if len(k) >= len(prefix) && k[:len(prefix)] == prefix { keys = append(keys, k) } }
|
||||
return keys, nil
|
||||
}
|
||||
func (s *bridgeSettings) GetCore(key string) (interface{}, error) { return nil, nil }
|
||||
func (s *bridgeSettings) SetCore(key string, value interface{}) error { return nil }
|
||||
func (s *bridgeSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
|
||||
func (s *bridgeSettings) GetPlugin(plugin, key string) (interface{}, error) { return nil, nil }
|
||||
func (s *bridgeSettings) SetPlugin(plugin, key string, value interface{}) error { return nil }
|
||||
func (s *bridgeSettings) ListPlugin(plugin, prefix string) ([]string, error) { return nil, nil }
|
||||
func (s *bridgeSettings) RegisterDef(def sdk.ConfigDef) {}
|
||||
func (s *bridgeSettings) Defs(prefix string) []*sdk.ConfigDef { return nil }
|
||||
func (s *bridgeSettings) Dump() map[string]interface{} { return s.data }
|
||||
func (s *bridgeSettings) Plugins() []string { return nil }
|
||||
|
||||
func main() {}
|
||||
`
|
||||
|
||||
// tmplCABIHeader — shared C ABI type definitions for both core and plugin
|
||||
// 此模板中的常量应与 core/internal/meta/meta.go 保持一致(ABI 版本、dispatch method IDs)。
|
||||
const tmplCABIHeader = `
|
||||
#ifndef HOMEAGENT_CABI_H
|
||||
#define HOMEAGENT_CABI_H
|
||||
// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go ABIVersion 同步
|
||||
#define HOMEAGENT_ABI_VERSION 1
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// PluginAPI — implemented by the plugin, called by the core
|
||||
typedef struct {
|
||||
int version; int version_min;
|
||||
int (*init_plugin)(char*, char*, char**);
|
||||
int (*start_plugin)(void*, int, char**);
|
||||
int (*stop_plugin)(char**);
|
||||
int (*invoke_tool)(char*, char*, char**, char**);
|
||||
int (*invoke_stage)(char*, char*, char**);
|
||||
int (*invoke_output)(char*, char*, char*, char**);
|
||||
void (*free_string)(char*);
|
||||
} PluginAPI;
|
||||
|
||||
// CoreAPI — implemented by the core, passed to plugin via start_plugin
|
||||
// Uses single dispatch function to avoid function pointer ABI issues
|
||||
typedef struct {
|
||||
int version; int version_min;
|
||||
int (*dispatch)(int method_id, void* ctx, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||
void* ctx;
|
||||
} CoreAPI;
|
||||
|
||||
// Dispatch method IDs (plugin→core SDK calls)
|
||||
enum {
|
||||
CORE_REGISTER_TOOL = 1,
|
||||
CORE_REGISTER_STAGE = 2,
|
||||
CORE_REGISTER_OUTPUT_CH = 3,
|
||||
CORE_REGISTER_PLUGIN_API = 4,
|
||||
CORE_INJECT_TEXT = 5,
|
||||
CORE_INJECT_INTERRUPT_TEXT = 6,
|
||||
CORE_INJECT_TEXT_NO_MEMORY = 7,
|
||||
CORE_SET_AUTO_RESTART = 8,
|
||||
CORE_MEMORY_RECALL = 9,
|
||||
CORE_MEMORY_COMMIT = 10,
|
||||
CORE_MEMORY_INTROSPECT = 11,
|
||||
CORE_MEMORY_MERGE = 12,
|
||||
CORE_MEMORY_PURGE = 13,
|
||||
CORE_DOC_QUERY = 14,
|
||||
CORE_KNOWLEDGE_SEARCH = 15,
|
||||
CORE_SETTINGS_GET = 16,
|
||||
CORE_SETTINGS_SET = 17,
|
||||
CORE_SETTINGS_REGISTER_DEF = 18,
|
||||
CORE_LLM_LIST_SOURCES = 19,
|
||||
CORE_LLM_SET_SOURCE = 20,
|
||||
CORE_SOCIAL_GET_PERSON = 21,
|
||||
CORE_SOCIAL_GET_NETWORK = 22,
|
||||
CORE_SUBSCRIBE = 23,
|
||||
CORE_UNSUBSCRIBE = 24,
|
||||
CORE_FREE_STRING = 25,
|
||||
CORE_SETTINGS_GET_CORE = 26,
|
||||
CORE_SETTINGS_SET_CORE = 27,
|
||||
CORE_SETTINGS_LIST_CORE = 28,
|
||||
CORE_SETTINGS_GET_PLUGIN = 29,
|
||||
CORE_SETTINGS_SET_PLUGIN = 30,
|
||||
CORE_SETTINGS_LIST_PLUGIN = 31,
|
||||
CORE_DOC_INSERT = 32,
|
||||
CORE_DOC_REMOVE = 33,
|
||||
CORE_DOC_STATS = 34,
|
||||
CORE_KNOWLEDGE_ADD = 35,
|
||||
CORE_KNOWLEDGE_LIST = 36,
|
||||
CORE_LLM_CURRENT_SOURCE = 37,
|
||||
CORE_SOCIAL_GET_TRAIT = 38,
|
||||
CORE_SOCIAL_GET_RELATIONS = 39,
|
||||
CORE_SOCIAL_LIST_PERSONS = 40,
|
||||
CORE_TEXT_MEMORY_APPEND = 41,
|
||||
CORE_SETTINGS_LIST = 42,
|
||||
CORE_SETTINGS_DEFS = 43,
|
||||
CORE_SETTINGS_DUMP = 44,
|
||||
CORE_SETTINGS_PLUGINS = 45,
|
||||
CORE_REGISTER_INPUT_CH = 46,
|
||||
struct transport_ctx {
|
||||
int sock;
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
static int transport_connect(void *ctx, const char *host, uint16_t port) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
struct hostent *he = gethostbyname(host);
|
||||
if (!he) return -1;
|
||||
tc->sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (tc->sock < 0) return -1;
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
|
||||
if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
close(tc->sock);
|
||||
tc->sock = -1;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int transport_send(void *ctx, const uint8_t *data, int len) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
int sent = 0;
|
||||
while (sent < len) {
|
||||
int n = (int)send(tc->sock, data + sent, len - sent, 0);
|
||||
if (n <= 0) return -1;
|
||||
sent += n;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
static int transport_recv(void *ctx, uint8_t *buf, int len) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
int n = (int)recv(tc->sock, buf, len, 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
static void transport_close(void *ctx) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
if (tc->sock >= 0) {
|
||||
close(tc->sock);
|
||||
tc->sock = -1;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ====================== 声明式命令处理 ======================
|
||||
*
|
||||
* 每个命令对应一个处理函数,通过填写 ha_cmd_result_t 返回数据。
|
||||
* SDK 自动回执结果,无需手动调用 send_result。
|
||||
*
|
||||
* 返回方式:
|
||||
* 1. 文本输出:填写 result->output
|
||||
* 2. 二进制数据:设置 result->has_binary=1 并填写 binary_data/len/mime
|
||||
* 3. 错误:设置 result->status=1 并填写 result->error
|
||||
* 4. 返回 HA_OK 表示处理成功,其他值表示处理失败
|
||||
*/
|
||||
|
||||
/* ESP32-CAM 摄像头处理 */
|
||||
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 = 0;
|
||||
if (args && args[0]) duration = atoi(args);
|
||||
printf("[camera] %s (duration=%ds)\n", duration ? "record" : "snapshot", duration);
|
||||
|
||||
/* 返回文本结果(base64 图片) */
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,/9j/4AAQ...";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 屏幕截图处理 */
|
||||
static ha_status_t handle_screensee(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)args; (void)userdata;
|
||||
printf("[screen] screenshot\n");
|
||||
result->status = 0;
|
||||
result->output = "data:image/png;base64,iVBORw0KGgo...";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 语音播报处理 */
|
||||
static ha_status_t handle_speakeruse(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[speaker] TTS: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "speakeruse done";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 远程操控处理(computeruse) */
|
||||
static ha_status_t handle_computeruse(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
const char *action = NULL;
|
||||
const char *json_str = NULL;
|
||||
ha_cmd_parse_json(args, &action, &json_str);
|
||||
printf("[computeruse] action=%s\n", action ? action : "unknown");
|
||||
result->status = 0;
|
||||
result->output = "computeruse done";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 剪贴板读取 */
|
||||
static ha_status_t handle_clipboardsee(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)args; (void)userdata;
|
||||
result->status = 0;
|
||||
result->output = "clipboard content";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 剪贴板写入 */
|
||||
static ha_status_t handle_clipboardsue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[clipboard] write: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "clipboard written";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 屏幕显示 */
|
||||
static ha_status_t handle_screensue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[screensue] show: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "screensue shown";
|
||||
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;
|
||||
printf("[shell] cmd: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "shell output";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 设备信息查询 */
|
||||
static ha_status_t handle_deviceinfo(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)args; (void)userdata;
|
||||
result->status = 0;
|
||||
result->output = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* ====================== 连接状态回调 ====================== */
|
||||
|
||||
static void on_state(int connected, void *userdata) {
|
||||
(void)userdata;
|
||||
printf("[devicelink] state: %s\n", connected ? "connected" : "disconnected");
|
||||
}
|
||||
|
||||
/* ====================== 主函数 ====================== */
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
/* 传输层上下文 */
|
||||
struct transport_ctx tctx;
|
||||
tctx.sock = -1;
|
||||
|
||||
ha_transport_t transport = {
|
||||
.connect = transport_connect,
|
||||
.send = transport_send,
|
||||
.recv = transport_recv,
|
||||
.close = transport_close,
|
||||
.ctx = &tctx,
|
||||
};
|
||||
|
||||
/* ===== 声明式设备配置 ===== */
|
||||
|
||||
/* 声明设备能力 */
|
||||
const char *caps[] = {
|
||||
"status", "cmdrun", "deviceinfo",
|
||||
"camerasue", "screensee", "speakeruse",
|
||||
"computeruse", "clipboardsee", "clipboardsue",
|
||||
"screensue",
|
||||
NULL
|
||||
};
|
||||
|
||||
/* 声明命令处理表:设备支持哪些命令,以及对应的处理函数 */
|
||||
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 = "computeruse", .handler = handle_computeruse},
|
||||
{.command = "clipboardsee", .handler = handle_clipboardsee},
|
||||
{.command = "clipboardsue", .handler = handle_clipboardsue},
|
||||
{.command = "screensue", .handler = handle_screensue},
|
||||
{.command = "deviceinfo", .handler = handle_deviceinfo},
|
||||
{.command = NULL}, /* 标记结束 */
|
||||
};
|
||||
|
||||
ha_config_t config = {
|
||||
.transport = transport,
|
||||
.server = "127.0.0.1:9890",
|
||||
.token = "your-token-here",
|
||||
.device = {
|
||||
.device_id = "{{.Plg.Name}}",
|
||||
.name = "{{.Plg.NameEn}}",
|
||||
.kind = "computer",
|
||||
.caps = caps,
|
||||
.info_json = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}",
|
||||
},
|
||||
.handlers = handlers, /* 声明式命令处理表 */
|
||||
.on_state = on_state,
|
||||
.ping_interval = 30,
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
if (!client) {
|
||||
fprintf(stderr, "Failed to create client\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Starting remote device adapter: {{.Plg.Name}}\n");
|
||||
printf(" Server: %s\n", config.server);
|
||||
printf(" Device ID: %s\n", config.device.device_id);
|
||||
printf(" Kind: %s\n", config.device.kind);
|
||||
printf(" Caps: ");
|
||||
for (const char **p = caps; *p; p++) printf("%s ", *p);
|
||||
printf("\n");
|
||||
|
||||
ha_status_t st = ha_client_start(client);
|
||||
if (st != HA_OK) {
|
||||
fprintf(stderr, "Failed to connect: %d\n", st);
|
||||
ha_client_destroy(client);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Connected! Entering main loop...\n");
|
||||
|
||||
/* 主循环 */
|
||||
while (1) {
|
||||
ha_status_t st = ha_client_process(client);
|
||||
if (st == HA_ERR_DISCONNECTED) {
|
||||
printf("Disconnected, exiting.\n");
|
||||
break;
|
||||
}
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
Sleep(10);
|
||||
#else
|
||||
usleep(10000);
|
||||
#endif
|
||||
}
|
||||
|
||||
ha_client_stop(client);
|
||||
ha_client_destroy(client);
|
||||
return 0;
|
||||
}
|
||||
`
|
||||
|
||||
// tmplLinuxBridge — auto-generated Go bridge for Linux c-shared builds.
|
||||
// Called by plugin's Start() with a PluginSDK that wraps CoreAPI dispatch.
|
||||
// PluginSDK calls go through C ABI → CoreAPI dispatch → core's Go PluginSDK.
|
||||
const tmplLinuxBridge = `package main
|
||||
const tmplRemoteDeviceCMake = `cmake_minimum_required(VERSION 3.10)
|
||||
project({{.Plg.Name}} VERSION 0.1.0 LANGUAGES C)
|
||||
|
||||
/*
|
||||
#include <stdlib.h>
|
||||
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
# ============================================================
|
||||
# {{.Plg.Name}} — Remote Device Adapter
|
||||
# ============================================================
|
||||
|
||||
# 设置 SDK 路径(默认使用内置 SDK,也可通过 -DSDK_PATH=... 指定)
|
||||
set(SDK_PATH "${CMAKE_CURRENT_SOURCE_DIR}/ha_remotedevice"
|
||||
CACHE PATH "Path to ha_remotedevice SDK")
|
||||
|
||||
# 添加 SDK 子目录
|
||||
if(EXISTS "${SDK_PATH}/CMakeLists.txt")
|
||||
add_subdirectory(${SDK_PATH} ha_remotedevice)
|
||||
else()
|
||||
message(FATAL_ERROR "ha_remotedevice SDK not found at ${SDK_PATH}")
|
||||
endif()
|
||||
|
||||
# 创建设备适配器可执行文件
|
||||
add_executable(${PROJECT_NAME}
|
||||
main.c
|
||||
)
|
||||
|
||||
// ---- global state ----
|
||||
# 链接 SDK
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE ha_remotedevice)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
currentPlg sdk.Plugin
|
||||
coreAPI unsafe.Pointer
|
||||
|
||||
handlerMu sync.RWMutex
|
||||
coreAPIMu sync.RWMutex
|
||||
toolHandlers = map[string]sdk.ToolHandler{}
|
||||
stageHandlers = map[string]sdk.StageHandler{}
|
||||
outputHandlers = map[string]sdk.ToolHandler{}
|
||||
# 包含 SDK 头文件
|
||||
target_include_directories(${PROJECT_NAME} PRIVATE
|
||||
${HA_REMOTEDEVICE_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
// ---- CoreAPI dispatch helpers ----
|
||||
# 编译选项
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(${PROJECT_NAME} PRIVATE
|
||||
-Wall -Wextra -Wpedantic
|
||||
-Wno-unused-parameter
|
||||
)
|
||||
endif()
|
||||
|
||||
func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error {
|
||||
coreAPIMu.RLock()
|
||||
api := coreAPI
|
||||
coreAPIMu.RUnlock()
|
||||
var c1, c2, c3 *C.char
|
||||
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
|
||||
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
|
||||
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
|
||||
var cErr *C.char
|
||||
if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil {
|
||||
return fmt.Errorf("%s", C.GoString(cErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) {
|
||||
coreAPIMu.RLock()
|
||||
api := coreAPI
|
||||
coreAPIMu.RUnlock()
|
||||
var c1, c2, c3 *C.char
|
||||
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
|
||||
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
|
||||
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
|
||||
var strResult, cErr *C.char
|
||||
if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil {
|
||||
return "", fmt.Errorf("%s", C.GoString(cErr))
|
||||
}
|
||||
if strResult != nil {
|
||||
result := C.GoString(strResult)
|
||||
C.ha_dispatch(C.int(25), api, strResult, nil, nil, 0, 0, nil, nil)
|
||||
return result, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ---- buildPluginSDK: PluginSDK backed by CoreAPI dispatch ----
|
||||
// - ALL SDK methods route through C ABI → CoreAPI → core's PluginSDK
|
||||
// - Handlers for tools/stages/output are stored locally AND registered via dispatch
|
||||
|
||||
func buildPluginSDK(name string) *sdk.PluginSDK {
|
||||
sett := &dispatchSettings{}
|
||||
base := sdk.New(name, sett,
|
||||
func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
handlerMu.Lock()
|
||||
toolHandlers[toolName] = handler
|
||||
handlerMu.Unlock()
|
||||
b, _ := json.Marshal(def)
|
||||
return callVoid(1, toolName, string(b), "", 0, 0)
|
||||
},
|
||||
func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
handlerMu.Lock()
|
||||
stageHandlers[string(stage)] = handler
|
||||
handlerMu.Unlock()
|
||||
callVoid(2, string(stage), "", "", 0, 0)
|
||||
},
|
||||
func(name string) error { return callVoid(4, name, "", "", 0, 0) },
|
||||
func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
|
||||
handlerMu.Lock()
|
||||
outputHandlers[name] = handler
|
||||
handlerMu.Unlock()
|
||||
defJSON, _ := json.Marshal(def)
|
||||
return callVoid(3, name, desc, string(defJSON), caps, 0)
|
||||
},
|
||||
)
|
||||
base.SetIOInjector(dispatchIO{})
|
||||
base.SetMemoryAPI(dispatchMemory{})
|
||||
base.SetDocMemoryAPI(dispatchDocMemory{})
|
||||
base.SetKnowledgeAPI(dispatchKnowledge{})
|
||||
base.SetLLMAPI(dispatchLLM{})
|
||||
base.SetSocialAPI(dispatchSocial{})
|
||||
base.SetTextMemoryAPI(dispatchTextMemory{})
|
||||
base.SetInputChannelRegistrar(
|
||||
func(name string, def sdk.ChannelDef) error {
|
||||
defJSON, _ := json.Marshal(def)
|
||||
return callVoid(46, name, string(defJSON), "", 0, 0)
|
||||
},
|
||||
)
|
||||
return base
|
||||
}
|
||||
|
||||
// ---- dispatch IO (inline definitions) ----
|
||||
|
||||
type dispatchIO struct{}
|
||||
func (dispatchIO) InjectInterruptText(s, c, t string) { callVoid(6, s, c, t, 0, 0) }
|
||||
func (dispatchIO) InjectText(s, c, t string) { callVoid(5, s, c, t, 0, 0) }
|
||||
func (dispatchIO) InjectTextNoMemory(s, c, t string) { callVoid(7, s, c, t, 0, 0) }
|
||||
|
||||
type dispatchMemory struct{}
|
||||
func (dispatchMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) {
|
||||
b, _ := json.Marshal(q); r, e := callString(9, string(b), "", "", d, 0)
|
||||
if e != nil || r == "" { return nil, nil, e }
|
||||
var v struct{ Entities []sdk.Entity; Relations []sdk.Relation }
|
||||
if e = json.Unmarshal([]byte(r), &v); e != nil { return nil, nil, e }
|
||||
if v.Entities == nil { v.Entities = []sdk.Entity{} }
|
||||
if v.Relations == nil { v.Relations = []sdk.Relation{} }
|
||||
return v.Entities, v.Relations, nil
|
||||
}
|
||||
func (dispatchMemory) Commit(t []sdk.Triple) error { b, _ := json.Marshal(t); return callVoid(10, string(b), "", "", 0, 0) }
|
||||
func (dispatchMemory) Introspect() (map[string]interface{}, error) { r, e := callString(11, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var m map[string]interface{}; return m, json.Unmarshal([]byte(r), &m) }
|
||||
func (dispatchMemory) MergeEntities(s, t string) (int, error) { return 1, callVoid(12, s, t, "", 0, 0) }
|
||||
func (dispatchMemory) Purge(c map[string]string, m string) (int, error) { b, _ := json.Marshal(c); i := 0; if m == "hard" { i = 1 }; return 1, callVoid(13, string(b), "", "", i, 0) }
|
||||
|
||||
type dispatchDocMemory struct{}
|
||||
func (dispatchDocMemory) Query(t string, k int) []*sdk.Doc { r, e := callString(14, t, "", "", k, 0); if e != nil || r == "" { return nil }; var d []*sdk.Doc; json.Unmarshal([]byte(r), &d); return d }
|
||||
func (dispatchDocMemory) Insert(doc *sdk.Doc) error { b, _ := json.Marshal(doc); return callVoid(32, string(b), "", "", 0, 0) }
|
||||
func (dispatchDocMemory) Remove(id string) { callVoid(33, id, "", "", 0, 0) }
|
||||
func (dispatchDocMemory) Stats() map[string]interface{} { r, e := callString(34, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m }
|
||||
|
||||
type dispatchKnowledge struct{}
|
||||
func (dispatchKnowledge) Search(q string, k int) ([]*sdk.Knowledge, error) { r, e := callString(15, q, "", "", k, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.Knowledge; return v, json.Unmarshal([]byte(r), &v) }
|
||||
func (dispatchKnowledge) Add(n, c string) error { return callVoid(35, n, c, "", 0, 0) }
|
||||
func (dispatchKnowledge) List() ([]string, error) { r, e := callString(36, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) }
|
||||
|
||||
type dispatchLLM struct{}
|
||||
func (dispatchLLM) ListSources() []string { r, e := callString(19, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v }
|
||||
func (dispatchLLM) SetSource(n string) error { return callVoid(20, n, "", "", 0, 0) }
|
||||
func (dispatchLLM) CurrentSource() string { r, e := callString(37, "", "", "", 0, 0); if e != nil || r == "" { return "" }; return r }
|
||||
|
||||
type dispatchSocial struct{}
|
||||
func (dispatchSocial) GetPerson(n string) (*sdk.PersonProfile, error) { r, e := callString(21, n, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v sdk.PersonProfile; return &v, json.Unmarshal([]byte(r), &v) }
|
||||
func (dispatchSocial) GetTrait(n, t string) (string, bool) { r, e := callString(38, n, t, "", 0, 0); if e != nil || r == "" { return "", false }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); v, _ := m["value"].(string); ok, _ := m["found"].(bool); return v, ok }
|
||||
func (dispatchSocial) GetRelations(name string) ([]sdk.SocialRelation, error) { r, e := callString(39, name, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []sdk.SocialRelation; return v, json.Unmarshal([]byte(r), &v) }
|
||||
func (dispatchSocial) GetNetwork(n string, d int) ([]*sdk.PersonProfile, error) { r, e := callString(22, n, "", "", d, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.PersonProfile; return v, json.Unmarshal([]byte(r), &v) }
|
||||
func (dispatchSocial) ListPersons() ([]string, error) { r, e := callString(40, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) }
|
||||
|
||||
type dispatchTextMemory struct{}
|
||||
func (dispatchTextMemory) Append(evt sdk.TextEvent) error { b, _ := json.Marshal(evt); return callVoid(41, string(b), "", "", 0, 0) }
|
||||
|
||||
// ---- dispatchSettings (inline) ----
|
||||
|
||||
type dispatchSettings struct{}
|
||||
func (d *dispatchSettings) Get(key string) (interface{}, error) {
|
||||
r, e := callString(16, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
|
||||
}
|
||||
func (d *dispatchSettings) Set(key string, value interface{}) error {
|
||||
b, _ := json.Marshal(value); return callVoid(17, key, string(b), "", 0, 0)
|
||||
}
|
||||
func (d *dispatchSettings) RegisterDef(def sdk.ConfigDef) { b, _ := json.Marshal(def); callVoid(18, string(b), "", "", 0, 0) }
|
||||
func (d *dispatchSettings) List(prefix string) ([]string, error) {
|
||||
r, e := callString(42, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
|
||||
}
|
||||
func (d *dispatchSettings) GetCore(key string) (interface{}, error) {
|
||||
r, e := callString(26, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
|
||||
}
|
||||
func (d *dispatchSettings) SetCore(key string, value interface{}) error {
|
||||
b, _ := json.Marshal(value); return callVoid(27, key, string(b), "", 0, 0)
|
||||
}
|
||||
func (d *dispatchSettings) ListCore(prefix string) ([]string, error) {
|
||||
r, e := callString(28, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
|
||||
}
|
||||
func (d *dispatchSettings) GetPlugin(plugin, key string) (interface{}, error) {
|
||||
r, e := callString(29, plugin, key, "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
|
||||
}
|
||||
func (d *dispatchSettings) SetPlugin(plugin, key string, value interface{}) error {
|
||||
b, _ := json.Marshal(value); return callVoid(30, plugin, key, string(b), 0, 0)
|
||||
}
|
||||
func (d *dispatchSettings) ListPlugin(plugin, prefix string) ([]string, error) {
|
||||
r, e := callString(31, plugin, prefix, "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
|
||||
}
|
||||
func (d *dispatchSettings) Defs(prefix string) []*sdk.ConfigDef {
|
||||
r, e := callString(43, prefix, "", "", 0, 0); if e != nil || r == "" { return nil }; var v []*sdk.ConfigDef; json.Unmarshal([]byte(r), &v); return v
|
||||
}
|
||||
func (d *dispatchSettings) Dump() map[string]interface{} {
|
||||
r, e := callString(44, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m
|
||||
}
|
||||
func (d *dispatchSettings) Plugins() []string {
|
||||
r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v
|
||||
}
|
||||
|
||||
// ---- Go callbacks (called from z_entry.c via C) ----
|
||||
|
||||
//export go_init_plugin
|
||||
func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int {
|
||||
plg, err := NewPlugin(C.GoString(name), nil)
|
||||
if err != nil || plg == nil {
|
||||
if err != nil { *errorOut = C.CString(err.Error()) } else { *errorOut = C.CString("NewPlugin returned nil") }
|
||||
return 1
|
||||
}
|
||||
mu.Lock(); currentPlg = plg; mu.Unlock()
|
||||
_ = configJSON
|
||||
return 0
|
||||
}
|
||||
|
||||
//export go_start_plugin
|
||||
func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int {
|
||||
mu.Lock()
|
||||
plg := currentPlg
|
||||
coreAPIMu.Lock()
|
||||
coreAPI = coreAPIptr
|
||||
coreAPIMu.Unlock()
|
||||
mu.Unlock()
|
||||
_ = coreVersion
|
||||
if plg == nil { *errorOut = C.CString("not initialized"); return 1 }
|
||||
sdk := buildPluginSDK(plg.Name())
|
||||
if err := plg.Start(sdk); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
//export go_stop_plugin
|
||||
func go_stop_plugin(errorOut **C.char) C.int {
|
||||
mu.Lock()
|
||||
plg := currentPlg
|
||||
currentPlg = nil
|
||||
coreAPIMu.Lock()
|
||||
coreAPI = nil
|
||||
coreAPIMu.Unlock()
|
||||
mu.Unlock()
|
||||
if plg != nil {
|
||||
if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export go_invoke_tool
|
||||
func go_invoke_tool(name *C.char, argsJSON *C.char, resultOut **C.char, errorOut **C.char) C.int {
|
||||
goName := C.GoString(name)
|
||||
handlerMu.RLock()
|
||||
h, ok := toolHandlers[goName]
|
||||
handlerMu.RUnlock()
|
||||
if !ok { *errorOut = C.CString("tool not found"); return 1 }
|
||||
var args map[string]interface{}
|
||||
if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) }
|
||||
r, err := h(args)
|
||||
if err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||
b, _ := json.Marshal(r)
|
||||
*resultOut = C.CString(string(b))
|
||||
return 0
|
||||
}
|
||||
|
||||
//export go_invoke_stage
|
||||
func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int {
|
||||
goStage := C.GoString(stage)
|
||||
handlerMu.RLock()
|
||||
h, ok := stageHandlers[goStage]
|
||||
handlerMu.RUnlock()
|
||||
if !ok { return 0 }
|
||||
sc := &sdk.StageContext{}
|
||||
if ctxJSON != nil {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(C.GoString(ctxJSON)), &m); err == nil {
|
||||
if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
|
||||
if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
|
||||
if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
|
||||
if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
|
||||
if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
|
||||
if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
|
||||
if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
|
||||
if v, _ := m["response"].(string); v != "" { sc.Response = &v }
|
||||
if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
|
||||
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
|
||||
}
|
||||
if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
|
||||
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
//export go_invoke_output
|
||||
func go_invoke_output(channel *C.char, msgType *C.char, payloadJSON *C.char, errorOut **C.char) C.int {
|
||||
goChan := C.GoString(channel)
|
||||
handlerMu.RLock()
|
||||
h, ok := outputHandlers[goChan]
|
||||
handlerMu.RUnlock()
|
||||
if !ok { return 0 }
|
||||
// payloadJSON contains the full args JSON from output_send (e.g. {"content":"...","user_id":123})
|
||||
var args map[string]interface{}
|
||||
if payloadJSON != nil {
|
||||
json.Unmarshal([]byte(C.GoString(payloadJSON)), &args)
|
||||
}
|
||||
if _, err := h(args); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
//export go_free_string
|
||||
func go_free_string(ptr *C.char) { C.free(unsafe.Pointer(ptr)) }
|
||||
|
||||
func main() {}
|
||||
`
|
||||
|
||||
// tmplPluginInitC — C entry point for the plugin .so file.
|
||||
// Contains PluginAPI, CoreAPI (single dispatch), and ha_dispatch bridge.
|
||||
const tmplPluginInitC = `#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define HOMEAGENT_ABI_VERSION 1
|
||||
|
||||
typedef struct {
|
||||
int version; int version_min;
|
||||
int (*init_plugin)(char*, char*, char**);
|
||||
int (*start_plugin)(void*, int, char**);
|
||||
int (*stop_plugin)(char**);
|
||||
int (*invoke_tool)(char*, char*, char**, char**);
|
||||
int (*invoke_stage)(char*, char*, char**);
|
||||
int (*invoke_output)(char*, char*, char*, char**);
|
||||
void (*free_string)(char*);
|
||||
} PluginAPI;
|
||||
|
||||
typedef struct {
|
||||
int version; int version_min;
|
||||
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
|
||||
void* ctx;
|
||||
} CoreAPI;
|
||||
|
||||
extern int go_init_plugin(char*, char*, char**);
|
||||
extern int go_start_plugin(void*, int, char**);
|
||||
extern int go_stop_plugin(char**);
|
||||
extern int go_invoke_tool(char*, char*, char**, char**);
|
||||
extern int go_invoke_stage(char*, char*, char**);
|
||||
extern int go_invoke_output(char*, char*, char*, char**);
|
||||
extern void go_free_string(char*);
|
||||
|
||||
int c_init_plugin(char* n, char* c, char** e) { return go_init_plugin(n, c, e); }
|
||||
int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); }
|
||||
int c_stop_plugin(char** e) { return go_stop_plugin(e); }
|
||||
int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); }
|
||||
int c_invoke_stage(char* s, char* c, char** e) { return go_invoke_stage(s, c, e); }
|
||||
int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); }
|
||||
void c_free_string(char* p) { go_free_string(p); }
|
||||
|
||||
// ha_dispatch — called by Go bridge, passes through to CoreAPI dispatch
|
||||
int ha_dispatch(int id, void* api, char* s1, char* s2, char* s3, int i1, int i2, char** r, char** e) {
|
||||
CoreAPI* a = (CoreAPI*)api;
|
||||
if (!a || !a->dispatch) return 1;
|
||||
return a->dispatch(id, a->ctx, s1, s2, s3, i1, i2, r, e);
|
||||
}
|
||||
|
||||
PluginAPI* plugin_init(void) {
|
||||
static PluginAPI api;
|
||||
memset(&api, 0, sizeof(api));
|
||||
api.version = HOMEAGENT_ABI_VERSION; api.version_min = HOMEAGENT_ABI_VERSION;
|
||||
api.init_plugin = c_init_plugin; api.start_plugin = c_start_plugin; api.stop_plugin = c_stop_plugin;
|
||||
api.invoke_tool = c_invoke_tool; api.invoke_stage = c_invoke_stage; api.invoke_output = c_invoke_output;
|
||||
api.free_string = c_free_string;
|
||||
return &api;
|
||||
}
|
||||
# 安装
|
||||
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin)
|
||||
`
|
||||
|
||||
const tmplReadme = `# {{.Plg.Name}}
|
||||
|
||||
@ -17,3 +17,8 @@ curl -X POST http://localhost:8080/api/v1/plugins \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @dist/<name_en_snake>_linux_amd64.hmap
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `RegisterStopHandler` — runs on every stop (including reload/disable), before `Stop()`.
|
||||
- `RegisterOnRemoveHandler` — runs **only on uninstall (remove)**, after `Stop()`; clean up the plugin's own data files here. Reload/disable do NOT trigger it. See the onRemove demo in `main.go`.
|
||||
|
||||
@ -20,6 +20,12 @@ func (p *Plugin) Name() string { return p.name }
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
s.RegisterStopHandler(func() {
|
||||
fmt.Printf("[%s] stop handler running\n", p.name)
|
||||
})
|
||||
s.RegisterOnRemoveHandler(func() {
|
||||
fmt.Printf("[%s] onRemove handler running\n", p.name)
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.{{.Plg.Name}}.example",
|
||||
Default: "hello",
|
||||
|
||||
1291
tools/plugindev/templates/proc_main.go.tmpl
Normal file
1291
tools/plugindev/templates/proc_main.go.tmpl
Normal file
File diff suppressed because it is too large
Load Diff
62
tools/plugindev/templates/proc_shm_unix.go.tmpl
Normal file
62
tools/plugindev/templates/proc_shm_unix.go.tmpl
Normal 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 netpoller,Read 阻塞时只 park goroutine,
|
||||
// 不占 OS 线程(实验 1:200 个等待者仅增 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
|
||||
}
|
||||
153
tools/plugindev/templates/proc_shm_windows.go.tmpl
Normal file
153
tools/plugindev/templates/proc_shm_windows.go.tmpl
Normal file
@ -0,0 +1,153 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Windows 侧共享段挂载:走命名对象而非继承 fd。
|
||||
//
|
||||
// 为何不能照抄 Unix:Windows 没有 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 路径省线程。每插件一个消费 goroutine,17 插件即 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
|
||||
}
|
||||
Reference in New Issue
Block a user