mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-25 03:18:06 +00:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 255d6479ad | |||
| 9b6abe1b73 | |||
| 0a6e2b7dc4 | |||
| e97cafc8de | |||
| 5af2a86816 | |||
| 7717bf5ca5 | |||
| db207fd9b7 | |||
| 953fbb2f50 | |||
| 7ef9bc2ad3 | |||
| cfa72df3e9 | |||
| fe1c4cdb09 | |||
| a01fe21ab1 | |||
| f09891f054 | |||
| efb396d7b3 | |||
| 4852d70d77 | |||
| 63b6eafaf0 | |||
| 21221f20c5 | |||
| 11303e3ee4 | |||
| f4f6968987 | |||
| da046b2520 | |||
| 4cb3a0bda4 | |||
| e50bffa34f | |||
| 934eb4da7d | |||
| 4f4a03d368 | |||
| 4482235312 | |||
| 4cf2df5be6 | |||
| ebd700eaf9 | |||
| 7c0b7a1fb0 | |||
| 8c10b7ecc7 | |||
| fcb7490f63 | |||
| 9206353858 | |||
| 83a54f321e | |||
| b93fe6b878 | |||
| 140cd34b56 | |||
| b237787c90 | |||
| 69ff3089a4 | |||
| e839eb8220 | |||
| d893bfa76f | |||
| 93ab794a82 | |||
| 12cabcb290 | |||
| 44bd915fbf | |||
| ba49dfda44 | |||
| b2eafdf885 | |||
| 8c397ecf65 | |||
| 632f6743d3 | |||
| 9d930db4ea | |||
| 0a164fe4b9 | |||
| fd5a291df1 | |||
| 5175e7d6e0 | |||
| 71e3325439 | |||
| 18fec9b003 | |||
| fc236120e3 | |||
| a66739e59b | |||
| da01af1ad7 | |||
| 741e284cd4 | |||
| ce5bff9275 | |||
| 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 | |||
| 87136057b1 | |||
| 84bf100a12 | |||
| 78ef7998c2 | |||
| b166697dd7 | |||
| 4d01e75282 | |||
| c5bcae9404 | |||
| c7c66b8d39 |
20
.gitignore
vendored
20
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.so
|
*.so
|
||||||
*.dll
|
*.dll
|
||||||
|
*.o
|
||||||
|
*.exe
|
||||||
*.hmap
|
*.hmap
|
||||||
plugin.json
|
plugin.json
|
||||||
|
|
||||||
@ -8,6 +10,11 @@ plugin.json
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
|
||||||
|
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB),每次重编都在历史里
|
||||||
|
# 再叠一份,而它们本质是可从源码复现的产物。
|
||||||
|
bin/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
testdist/
|
testdist/
|
||||||
|
|
||||||
@ -25,3 +32,16 @@ z_entry.c
|
|||||||
# Pre-built plugindev binaries in bin/ should be tracked
|
# Pre-built plugindev binaries in bin/ should be tracked
|
||||||
!bin/plugindev*
|
!bin/plugindev*
|
||||||
!bin/*.exe
|
!bin/*.exe
|
||||||
|
|
||||||
|
# plugindev binary in tools/
|
||||||
|
tools/plugindev/plugindev
|
||||||
|
|
||||||
|
# 文档站构建产物(由 tools/apidoc/build.sh 生成)
|
||||||
|
site_build/
|
||||||
|
# mkdocs 缓存
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# agent 入口的「全文汇总」是派生件:由 gensite 把 docs/ 下所有 Markdown 拼成一份,
|
||||||
|
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
|
||||||
|
# llms.txt(索引,小且稳定)仍提交。
|
||||||
|
docs/llms-full.txt
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 JianFeeeee
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
796
README.md
796
README.md
@ -2,6 +2,108 @@
|
|||||||
|
|
||||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||||
|
|
||||||
|
## 版本与兼容性
|
||||||
|
|
||||||
|
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
|
||||||
|
|
||||||
|
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||||
|
|
||||||
|
| 内核版本 | 对应 SDK |
|
||||||
|
|---|---|
|
||||||
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
|
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||||
|
| 1.3.0 起 | **1.3.0** |
|
||||||
|
|
||||||
|
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||||
|
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||||
|
|
||||||
|
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
|
||||||
|
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
|
||||||
|
|
||||||
|
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
||||||
|
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
||||||
|
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
||||||
|
想用新字段时重编即可。
|
||||||
|
|
||||||
|
**1.1.x 插件升到 1.2.x:接口纯追加,但必须重编。** 公开接口没有签名变更(新增
|
||||||
|
`InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响;
|
||||||
|
但内核的**插件运行协议升到了 2**(统一共享内存区的 fd3 布局改变,**不支持滚动升级**),
|
||||||
|
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
||||||
|
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
||||||
|
|
||||||
|
## 1.3.0 新增:注入优先级与动态输出通道
|
||||||
|
|
||||||
|
### 注入优先级(`InjectOptions.Priority`)
|
||||||
|
|
||||||
|
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
|
||||||
|
|
||||||
|
| 级别 | 常量 | 谁用 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
|
||||||
|
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
|
||||||
|
|
||||||
|
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
|
||||||
|
⇒ 存量插件不需要改一行、也不需要重编。
|
||||||
|
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
|
||||||
|
(现场保存/恢复对插件透明)。
|
||||||
|
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
|
||||||
|
|
||||||
|
### 动态输出通道(`UnregisterOutputChannel`)
|
||||||
|
|
||||||
|
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
|
||||||
|
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
|
||||||
|
|
||||||
|
1.3.0 起成对提供:
|
||||||
|
|
||||||
|
| API | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
|
||||||
|
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
|
||||||
|
|
||||||
|
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
|
||||||
|
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
|
||||||
|
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
|
||||||
|
派生规则与约束见下方「输出通道」一节。
|
||||||
|
|
||||||
|
## 注入行为与上下文裁剪(1.2.0)
|
||||||
|
|
||||||
|
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
|
||||||
|
1.2.0 起**注入侧也能声明**,并且二者共用同一套语义与取值。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool // true = 不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
ContextPolicy string // ""/none = 不裁剪(默认);prune = 据此裁剪上下文
|
||||||
|
CleanerName string // 计算层过滤函数名:先经 Cleaner 得到实际有效内容,再计算/裁剪
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 六个变体,与旧的三参数方法一一对应,只多一个 opts
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- **零值 `InjectOptions{}` 与旧的三参数方法逐键等价**(记入记忆 + 不裁剪)。旧方法保留为
|
||||||
|
零值糖(`InjectText` / `InjectInterruptText` / `InjectTextNoMemory` …),存量插件不改一行、
|
||||||
|
不需重编即可继续调用。
|
||||||
|
- **裁剪(`prune`)必须显式声明**:它会归档丢弃低相关事件,是有副作用的行为,故默认关闭。
|
||||||
|
内核只放行 `""` / `none` / `prune`(`ValidContextPolicy`),未声明的取值会被拒。
|
||||||
|
- 裁剪前先经该插件注册的 **`Cleaner`**(由 `CleanerName` 指定)拿到实际有效内容,
|
||||||
|
避开「按原文裁剪、按清洗后计算」这种不一致。
|
||||||
|
- `ChannelDef` 也有同名 `context_policy`(并且 1.2.0 给它补上了 JSON tag——通道定义要跨进程
|
||||||
|
传给内核,而 `Cleaner` 是函数必须忽略;无 tag 时新增字段会被静默丢掉)。
|
||||||
|
|
||||||
## SDK API 接口
|
## SDK API 接口
|
||||||
|
|
||||||
### Plugin 接口
|
### Plugin 接口
|
||||||
@ -20,10 +122,15 @@ type Plugin interface {
|
|||||||
|
|
||||||
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
||||||
|
|
||||||
|
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
|
||||||
|
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**,
|
||||||
|
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
|
||||||
|
|
||||||
| 分类 | 方法 | 说明 |
|
| 分类 | 方法 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输出通道,caps 为能力位掩码 |
|
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||||
|
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
|
||||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||||
@ -35,6 +142,7 @@ type Plugin interface {
|
|||||||
| 设置 | `Settings()` | 访问设置 API |
|
| 设置 | `Settings()` | 访问设置 API |
|
||||||
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
||||||
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
||||||
|
| 多模态注入 | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | 注入带图片/音频的输入(1.1.0 新增) |
|
||||||
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
||||||
|
|
||||||
### 阶段钩子
|
### 阶段钩子
|
||||||
@ -47,10 +155,38 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
|||||||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 输出通道
|
### ChannelDef
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler)
|
type ChannelDef struct {
|
||||||
|
NoMemory bool // 通道输入/输出不参与记忆计算(向量/关键词/蒸馏),原文保留
|
||||||
|
Cleaner func(string) string // 可选:计算层过滤函数(不改原文)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChannelDef` 控制通道在记忆计算层的行为,与 `ToolDef` 的 `NoMemory`/`Cleaner` 语义一致。
|
||||||
|
|
||||||
|
### 输入通道
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterInputChannel("qq", ChannelDef{
|
||||||
|
NoMemory: true,
|
||||||
|
Cleaner: func(text string) string { return strings.TrimSpace(text) },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输出通道
|
||||||
|
|
||||||
|
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
|
||||||
|
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
|
||||||
|
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
|
||||||
|
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
|
||||||
|
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
|
||||||
|
> 内核**不会**替你净化。
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
||||||
```
|
```
|
||||||
|
|
||||||
handler 接收三个参数:
|
handler 接收三个参数:
|
||||||
@ -85,6 +221,30 @@ type 枚举值:
|
|||||||
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
||||||
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
||||||
|
|
||||||
|
### 多模态注入(1.1.0 新增)
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `InjectInputMedia(source, channel, text, blocks)` | 注入带媒体的输入,异步 |
|
||||||
|
| `InjectInputMediaSync(source, channel, text, blocks)` | 注入带媒体的输入并同步等待回复文本 |
|
||||||
|
| `InjectInterruptMedia(source, channel, text, blocks)` | 注入带媒体的中断,可抢占当前处理 |
|
||||||
|
|
||||||
|
`blocks` 是 `[]sdk.ContentBlock`,与 `SetToolBlocks` 用同一类型:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.InjectInputMedia("myplugin", "webui", "帮我看看这张图", []sdk.ContentBlock{{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
与 `SetToolBlocks` 的区别:`SetToolBlocks` 只能在工具处理函数内部调用,媒体要等到
|
||||||
|
下一条 tool message 才到模型手上;这三个方法是插件**主动发起一轮带媒体的对话**,
|
||||||
|
媒体在本轮就随消息发给模型,并自动落进媒体存储、挂上媒体记忆引用。
|
||||||
|
|
||||||
|
媒体块里的 `data:` URL 会被内核落盘去重;`http(s)` URL 只透传给模型,不入库
|
||||||
|
(入库需要内核发起网络请求,涉及超时、鉴权与 SSRF)。
|
||||||
|
|
||||||
`source` 标识来源,`channel` 指定目标输出通道。
|
`source` 标识来源,`channel` 指定目标输出通道。
|
||||||
|
|
||||||
### Triple 扩展字段
|
### Triple 扩展字段
|
||||||
@ -94,6 +254,61 @@ Triple 数据结构新增字段:
|
|||||||
- `Confidence` — 置信度(0.0~1.0)
|
- `Confidence` — 置信度(0.0~1.0)
|
||||||
- `SubjectType` — 主体类型
|
- `SubjectType` — 主体类型
|
||||||
- `ObjectType` — 客体类型
|
- `ObjectType` — 客体类型
|
||||||
|
- `SentenceText` — 原始句子文本(1.1.0 新增),写入 `sentences` 表;媒体引用挂在句子上
|
||||||
|
- `MediaDigests` — 关联的媒体 digest 列表(1.1.0 新增)
|
||||||
|
|
||||||
|
### 记忆里的媒体(1.1.0 新增)
|
||||||
|
|
||||||
|
媒体在纯文本记忆里以**标记**形式存在,格式 `[<mime> <短digest>] <描述>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||||
|
```
|
||||||
|
|
||||||
|
描述文本是持久的语义记忆(检索靠它),digest 是回到字节的钥匙(反查靠它)。
|
||||||
|
标记由内核生成,插件不必自己拼——**填 digest 就够**。
|
||||||
|
|
||||||
|
#### 图记忆
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.Memory().Commit([]sdk.Triple{{
|
||||||
|
Subject: "配色图", Relation: "包含", Object: "三色带",
|
||||||
|
MediaDigests: []string{"a1b2c3d4e5f6"}, // 短 digest 即可,内核补全
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
没给 `SentenceText` 时内核会用标记本身充当句子——媒体必须有句子落点,
|
||||||
|
否则引用无从挂起。
|
||||||
|
|
||||||
|
#### 知识库
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||||
|
Title: "带图笔记",
|
||||||
|
Content: "正文",
|
||||||
|
}, []sdk.MediaAttachment{
|
||||||
|
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // 新内容,落盘去重
|
||||||
|
{Digest: "a1b2c3d4e5f6"}, // 引用已有内容
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Insert` 保持原签名不变,正文里已有的标记同样会被挂成文档级引用。
|
||||||
|
`Query` 返回的 `Doc` 带 `MediaDigests` 与 `Attachments`(mime + 描述,
|
||||||
|
**不含字节**——一次检索可能命中几十份媒体)。删除文档时引用自动释放。
|
||||||
|
|
||||||
|
#### 文本记忆
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.TextMemory().Append(sdk.TextEvent{
|
||||||
|
Role: "user", Content: "看这张图",
|
||||||
|
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecentEvents` 读回时正文里的标记会被反解成 `Attachments`。
|
||||||
|
|
||||||
|
媒体存储可在内核侧关闭(`core.memory.media.enabled=false`),此时以上接口
|
||||||
|
全部退化为纯文本行为:不报错、不 panic,与本特性上线前一致。
|
||||||
|
|
||||||
### ToolDef 字段说明
|
### ToolDef 字段说明
|
||||||
|
|
||||||
@ -117,21 +332,51 @@ Triple 数据结构新增字段:
|
|||||||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||||||
```
|
```
|
||||||
|
|
||||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。
|
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
||||||
|
|
||||||
## plugindev 工具链
|
## hmapdev 工具链
|
||||||
|
|
||||||
`plugindev` 提供插件开发全流程支持:
|
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
||||||
|
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
||||||
|
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
||||||
|
|
||||||
|
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
||||||
|
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
|
> (旧目录会被自动沿用,不会丢已装版本)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
|
||||||
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
||||||
|
chmod +x hmapdev
|
||||||
|
|
||||||
|
# 或从源码自己编
|
||||||
|
cd tools/hmapdev && go build -o hmapdev .
|
||||||
|
```
|
||||||
|
|
||||||
|
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
|
||||||
|
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) |
|
| `hmapdev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
||||||
| `plugindev build` | 构建插件,输出 .hmap 包 |
|
| `hmapdev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
||||||
| `plugindev clean` | 清理构建产物 |
|
| `hmapdev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
||||||
| `plugindev debug` | 本地调试模式运行插件 |
|
| `hmapdev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
||||||
|
| `hmapdev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
||||||
|
|
||||||
支持 **Go** 和 **Lua** 两种插件语言。
|
支持 **Go** 和 **Lua** 两种插件语言。
|
||||||
|
|
||||||
|
### build 命令 flags
|
||||||
|
|
||||||
|
| Flag | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `--outdir <dir>` | 输出目录(默认 `dist`,可覆盖 plg.json 中的 `outdir`) |
|
||||||
|
| `--target <os/arch>` | 构建目标(如 `linux/amd64`),可重复指定(追加到 plg.json 中的 targets) |
|
||||||
|
| `--bundle` | 强制 bundle 模式(同时编译 linux/amd64, darwin/amd64, windows/amd64) |
|
||||||
|
| `--no-bundle` | 关闭 bundle 模式,仅按 targets 逐个编译 |
|
||||||
|
| `--sdk-path <path>` | 指定 SDK 源码路径(覆盖 plg.json 中的 `sdk_path`) |
|
||||||
|
| `--replace <from=to>` / `-R` | Go 模块替换(追加到 plg.json 中的 replaces),`from` 为模块路径,`to` 为本地路径 |
|
||||||
|
|
||||||
### plg.json 清单格式
|
### plg.json 清单格式
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -142,7 +387,7 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "天气查询插件",
|
"description": "天气查询插件",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.bin",
|
||||||
"tags": ["weather", "forecast"],
|
"tags": ["weather", "forecast"],
|
||||||
"targets": "linux/amd64,windows/amd64",
|
"targets": "linux/amd64,windows/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
@ -164,29 +409,73 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
| `version` | string | 版本号 |
|
| `version` | string | 版本号 |
|
||||||
| `description` | string | 插件描述 |
|
| `description` | string | 插件描述 |
|
||||||
| `author` | string | 作者 |
|
| `author` | string | 作者 |
|
||||||
| `entry` | string | 入口文件(`plugin.so` / `main.lua`) |
|
| `entry` | string | 入口文件(`plugin.bin` / `main.lua`)。v1.0.0 起 Go 插件统一为 `plugin.bin`,不再区分平台后缀 |
|
||||||
| `tags` | string[] | 标签 |
|
| `tags` | string[] | 标签 |
|
||||||
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`) |
|
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) |
|
||||||
| `outdir` | string | 输出目录(默认 `dist`) |
|
| `outdir` | string | 输出目录(默认 `dist`) |
|
||||||
| `bundle` | bool | 是否 bundle 模式(同时编译多平台) |
|
| `bundle` | bool | 是否 bundle 模式(同时编译多平台,默认 `true`) |
|
||||||
|
| `sdk_path` | string | SDK 源码路径(覆盖自动检测的 SDK 路径) |
|
||||||
|
| `go_version` | string | Go 版本(如 `1.21`,默认从 SDK 的 go.mod 读取) |
|
||||||
| `replaces` | object | Go 模块替换,key=模块路径,value=本地路径 |
|
| `replaces` | object | Go 模块替换,key=模块路径,value=本地路径 |
|
||||||
| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入) |
|
| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入,用于引入 `thirdpart/` 外部的共享代码) |
|
||||||
|
|
||||||
### .hmap 包格式
|
### .hmap 包格式
|
||||||
|
|
||||||
`.hmap` 为 ZIP 归档,包含:
|
`.hmap` 为 ZIP 归档,包含:
|
||||||
|
|
||||||
- `plugin.json` — 插件元数据
|
- `plugin.json` — 插件元数据
|
||||||
- `plugin.so` — Go 编译产物(Linux)
|
- `plugin.bin` — Go 编译产物(单平台构建)
|
||||||
- `plugin.dll` — Go 编译产物(Windows)
|
- `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
|
||||||
|
安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
|
||||||
- `main.lua` — Lua 插件入口(Lua 插件时)
|
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||||
|
|
||||||
|
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
|
||||||
|
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
|
||||||
|
|
||||||
## 插件生命周期
|
## 插件生命周期
|
||||||
|
|
||||||
|
### 入口函数
|
||||||
|
|
||||||
|
插件必须导出 `NewPluginFactory` 入口函数(Go)或 `start()` 函数(Lua):
|
||||||
|
|
||||||
|
**Go 插件** — 实现 `Plugin` 接口并导出工厂函数:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该函数由内核在加载插件时调用,`name` 为插件名,`config` 为 `skill.json` 中的配置(如有)。
|
||||||
|
|
||||||
|
**Lua 插件** — 返回包含 `start(sdk)` 和 `stop()` 方法的 table:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local plugin = { name = "my-plugin" }
|
||||||
|
function plugin.start(sdk) -- 注册工具等 end
|
||||||
|
function plugin.stop() end
|
||||||
|
return plugin
|
||||||
|
```
|
||||||
|
|
||||||
### 启动与停止
|
### 启动与停止
|
||||||
|
|
||||||
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
||||||
- `Stop() error` — 插件停止,释放资源
|
- `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`(删缓存目录);`hmapdev` 模板含 onRemove 演示。
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterOnRemoveHandler(func() {
|
||||||
|
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### 自动重启
|
### 自动重启
|
||||||
|
|
||||||
@ -198,6 +487,43 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
插件崩溃时平台自动拉起,保障服务可用性。
|
插件崩溃时平台自动拉起,保障服务可用性。
|
||||||
|
|
||||||
|
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
|
||||||
|
|
||||||
|
| 参数 | 值 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
|
||||||
|
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
|
||||||
|
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
|
||||||
|
|
||||||
|
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
|
||||||
|
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
|
||||||
|
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
|
||||||
|
|
||||||
|
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
||||||
|
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
||||||
|
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
||||||
|
> 生产表现是插件重载瞬间偶发 nil 解引用崩溃)。早于 1.1.0 的版本建议升级。
|
||||||
|
|
||||||
|
## 插件开发者的并发约定
|
||||||
|
|
||||||
|
`PluginSDK` 是**被多个 goroutine 同时使用的共享对象**:你在 `Start()` 里起的轮询、
|
||||||
|
监听、定时器都拿着同一份 `*PluginSDK` 往里注消息,而内核会在加载/重载时写它的
|
||||||
|
API 字段。因此:
|
||||||
|
|
||||||
|
- **SDK 侧已保证的**:全部 API 访问器(`Memory()`/`DocMemory()`/…)、全部注入方法、
|
||||||
|
`SetAutoRestart`/`AutoRestart`、`RegisterTool`/`RegisterStage`、
|
||||||
|
`RunStopHandlers`/`RunOnRemoveHandlers`(幂等,并发调也只执行一次)。
|
||||||
|
- **你需要自己保证的**:`StageContext` 的字段全部导出,并发读写必须自己持
|
||||||
|
`ctx.Lock()`/`ctx.RLock()`。尤其是 `ctx.Extra`——**map 的并发写在 Go 里是直接 fatal,
|
||||||
|
`recover` 接不住**。
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.Extra["mykey"] = value
|
||||||
|
ctx.FinalText += "补充说明"
|
||||||
|
ctx.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
## 受限 SDK vs 完整 SDK
|
## 受限 SDK vs 完整 SDK
|
||||||
|
|
||||||
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
||||||
@ -209,37 +535,439 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
||||||
|
|
||||||
|
## 项目声明 SDK 版本(plg.json 的 `sdk` 字段)
|
||||||
|
|
||||||
|
`hmapdev init` 生成的工程里,`plg.json` 会带一个 `sdk` 字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MyPlugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"entry": "plugin.bin",
|
||||||
|
"sdk": "1.2.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
它的语义是**本插件针对的 SDK 版本**,工具链据此在本地 SDK 存储里选择版本:
|
||||||
|
命中就用它,并把 `go.mod` 的 `require`/`replace` 同步到该版本;未命中则**明确报错**
|
||||||
|
(列出已装版本 + `hmapdev sdk install vX.Y.Z`),**绝不静默退化成 `current`**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hmapdev build
|
||||||
|
[hmapdev] SDK 1.2.0(项目声明 sdk=1.2.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么要这个字段:以前项目里没有任何「我要哪版 SDK」的声明,工具链只能用存储里的
|
||||||
|
`current`——谁改过 `current` 就拿谁的版本编,出错时表现为一堆看不懂的编译错误
|
||||||
|
(例如存储里只有陈旧的 `v0.8.0` 时,模板项目首次构建会报 `undefined: sdk.InjectOptions`)。
|
||||||
|
|
||||||
|
**写法必须是完整版本号(`1.2.0`),不接受区间写法(`1.2`)。** 原因见上文的版本纪律:
|
||||||
|
SDK 版本跟随内核中版本、patch 位恒为 `.0`,一条内核线只对应一个 SDK 版本;
|
||||||
|
写区间会让人误以为同一条线里还能挑不同 SDK(工具链会直接拒绝并说明这条规矩)。
|
||||||
|
|
||||||
|
- 显式 `--sdk-path` 或 `plg.json` 的 `sdk_path` 优先(本机改 SDK 联调时用);
|
||||||
|
- 存量工程(`plg.json` 没有 `sdk` 字段)行为不变,仍按 `current` 构建;
|
||||||
|
- 产物 `.hmap` 里的 `plugin.json` 会记录**实际选中的 SDK 版本**,便于事后追溯。
|
||||||
|
|
||||||
|
## IDE 支持:VSCode 扩展(`tools/vscode-hmapdev`)
|
||||||
|
|
||||||
|
调试插件的实操回路是「构建 → 运行 → 看内核日志」,这三步都在 IDE 之外很别扭,
|
||||||
|
所以仓库里带了一个 VSCode 扩展([tools/vscode-hmapdev](tools/vscode-hmapdev)):
|
||||||
|
|
||||||
|
- **plg.json 诊断**:必需字段、`sdk` 是否是完整版本号、声明的 SDK 是否已安装(直接给安装命令);
|
||||||
|
- **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失或工程有错时变色;
|
||||||
|
- **命令 / 任务**:build / build(全部目标)/ clean / debug(解释执行),编译错误进 Problems;
|
||||||
|
- **跟随内核日志**:读 `<dataDir>/log` 下最新的 `homed_*.log` 并按插件名过滤。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode 里按 F5
|
||||||
|
```
|
||||||
|
|
||||||
|
它不是源码级调试器(没有断点/单步):插件要么编译成产物在内核里跑、要么用
|
||||||
|
`hmapdev debug` 解释执行,两条路都没有 DAP 会话;扩展做的是构建、运行、看日志与清单校验。
|
||||||
|
|
||||||
## 示例插件
|
## 示例插件
|
||||||
|
|
||||||
| 插件 | 说明 |
|
| 插件 | 类型 | 说明 |
|
||||||
|------|------|
|
|------|------|------|
|
||||||
| a2a | Agent-to-Agent 协议通信 |
|
| [weather](example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||||
| bili | Bilibili 视频下载 |
|
| [luademo](example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||||
| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch) |
|
| [qq](example/qq) | Go | QQ 消息集成(NapCat),17 个工具,输入/输出通道完整对接 |
|
||||||
| editdoc | 文档编辑 |
|
| [a2a](example/a2a) | Go | Agent-to-Agent 协议通信 |
|
||||||
| files | 文件管理 |
|
| [ai_image](example/ai_image) | Go | AI 图片生成 |
|
||||||
| memo | 备忘录/记忆 |
|
| [bili](example/bili) | Go | Bilibili 视频下载 |
|
||||||
| ocr | 光学字符识别 |
|
| [browser](example/browser) | Go | 网络搜索、网页抓取、浏览器渲染 |
|
||||||
| qq | QQ 消息集成 |
|
| [calendar](example/calendar) | Go | 日历管理 |
|
||||||
| sanitizer | 内容清洗/安全过滤 |
|
| [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 | 内容清洗/安全过滤 |
|
||||||
|
|
||||||
|
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
|
||||||
|
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核**协议绑定**(`ProtocolVersion`
|
||||||
|
+ 共享内存区魔数),只发工具链不发示例产物,很容易拿旧产物去装而握手失败——那看起来像
|
||||||
|
「插件坏了」而不是「版本不配套」。
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
通过 `hmapdev` 工具链初始化项目:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev 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/`
|
||||||
|
- **hmapdev 模板**: `hmapdev init --type remotedevice`
|
||||||
|
|
||||||
## 构建与安装
|
## 构建与安装
|
||||||
|
|
||||||
### 构建
|
### 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
输出 `.hmap` 包到项目目录。
|
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `hmapdev build --no-bundle`)。
|
||||||
|
|
||||||
### 安装
|
### 安装
|
||||||
|
|
||||||
通过 pluginmgr HTTP API 安装:
|
通过 pluginmgr HTTP API 安装(端口默认 9876,仅监听 127.0.0.1,无鉴权):
|
||||||
|
|
||||||
```bash
|
```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` 放入插件目录后重启平台。
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
SDK 以 **MIT** 发布,全文见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
|
**这是刻意的宽松**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
||||||
|
若用 AGPL 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了
|
||||||
|
让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈,
|
||||||
|
也无需取得任何例外或商业授权。第三方插件生态的安全与活跃正建立在这条之上。
|
||||||
|
|
||||||
|
前提是 SDK 本身**完全自包含**:`go.mod` 零外部依赖,`sdk/` 只依赖 Go 标准库
|
||||||
|
(`sync`),不引用核心仓的任何代码,因此 MIT 授权不与其他许可冲突。
|
||||||
|
|
||||||
|
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
||||||
|
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
||||||
|
不属于本 SDK,其许可全文随发行包放在 `/usr/share/doc/homeagent/licenses/`。
|
||||||
|
|||||||
682
README_EN.md
682
README_EN.md
@ -2,6 +2,103 @@
|
|||||||
|
|
||||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||||
|
|
||||||
|
## Version and Compatibility
|
||||||
|
|
||||||
|
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
|
||||||
|
|
||||||
|
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||||||
|
|
||||||
|
| Kernel version | Matching SDK |
|
||||||
|
|---|---|
|
||||||
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
|
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||||
|
| 1.3.0 onward | **1.3.0** |
|
||||||
|
|
||||||
|
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||||||
|
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||||||
|
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||||||
|
has changed.
|
||||||
|
|
||||||
|
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
|
||||||
|
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
|
||||||
|
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
|
||||||
|
|
||||||
|
## New in 1.3.0: Injection Priority and Dynamic Output Channels
|
||||||
|
|
||||||
|
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
|
||||||
|
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
|
||||||
|
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
|
||||||
|
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
|
||||||
|
Queued input has no level: anything can jump ahead of it.
|
||||||
|
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
|
||||||
|
channels that die with their resource (one channel per remote device) can now be unregistered;
|
||||||
|
previously they lingered and the model kept "successfully" sending into a dead channel.
|
||||||
|
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
|
||||||
|
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
|
||||||
|
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
|
||||||
|
external IDs — never use the raw ID.
|
||||||
|
|
||||||
|
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||||||
|
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||||||
|
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||||||
|
because the handshake validates `ProtocolVersion`, not the SDK version). Rebuild only when you want
|
||||||
|
the new fields.
|
||||||
|
|
||||||
|
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
|
||||||
|
No public signature changed (the SDK adds `InjectOptions`, six `*Opts` variants and
|
||||||
|
`ChannelDef.ContextPolicy`), so not calling the new capabilities means not being affected — but the
|
||||||
|
kernel's **plugin protocol went to 2** (the fd3 layout of the unified shared-memory region changed,
|
||||||
|
and **rolling upgrades are not supported**). `plugin.bin` must therefore be rebuilt with the matching
|
||||||
|
`hmapdev` and installed **together with** the kernel; otherwise the handshake fails on protocol
|
||||||
|
version mismatch (the error says explicitly to rebuild with the matching hmapdev — it never
|
||||||
|
degrades silently).
|
||||||
|
|
||||||
|
## Injection Behaviour and Context Pruning (1.2.0)
|
||||||
|
|
||||||
|
"Should this go into memory" and "should the context be pruned based on this" used to be
|
||||||
|
something only `ToolDef` could declare. Since 1.2.0 **injections can declare them too**, sharing
|
||||||
|
the same semantics and values.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool // true = excluded from memory computation (vectorize/keywords/distill); the
|
||||||
|
// original text still stays in context
|
||||||
|
ContextPolicy string // ""/none = do not prune (default); prune = prune context based on this
|
||||||
|
CleanerName string // name of the compute-layer cleaner: run it first to get the effective
|
||||||
|
// content, then compute/prune on that
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Six variants, one-to-one with the older three-argument methods, plus opts
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- **A zero-valued `InjectOptions{}` is key-for-key equivalent to the older three-argument methods**
|
||||||
|
(recorded in memory, not pruned). The old methods remain as zero-value sugar (`InjectText`,
|
||||||
|
`InjectInterruptText`, `InjectTextNoMemory`, …), so existing plugins keep working without a single
|
||||||
|
line changed *or* a rebuild.
|
||||||
|
- **Pruning (`prune`) must be declared explicitly**: it archives/drops low-relevance events, which
|
||||||
|
is a side effect, so it is off by default. The kernel only accepts `""` / `none` / `prune`
|
||||||
|
(`ValidContextPolicy`); anything else is rejected.
|
||||||
|
- Pruning first goes through the plugin's registered **`Cleaner`** (named by `CleanerName`) to get
|
||||||
|
the effective content, avoiding the inconsistency of "prune on the raw text, compute on the
|
||||||
|
cleaned text".
|
||||||
|
- `ChannelDef` carries the same `context_policy` (1.2.0 also gave `ChannelDef` JSON tags — the
|
||||||
|
definition crosses the process boundary, while `Cleaner` is a function that must be ignored; with
|
||||||
|
no tags, newly added fields would be silently dropped).
|
||||||
|
|
||||||
## SDK API Surface
|
## SDK API Surface
|
||||||
|
|
||||||
### Plugin Interface
|
### Plugin Interface
|
||||||
@ -23,7 +120,8 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
|||||||
| Category | Method | Description |
|
| Category | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
|
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
|
||||||
| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask |
|
| Input Channel | `RegisterInputChannel(name, def)` | Register input channel with `ChannelDef` (NoMemory/Cleaner) |
|
||||||
|
| Output Channel | `RegisterOutputChannel(name, caps, desc, def, handler)` | Register output channel with `ChannelDef` and capability bitmask |
|
||||||
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
|
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
|
||||||
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
|
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
|
||||||
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
|
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
|
||||||
@ -35,6 +133,7 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
|||||||
| Settings | `Settings()` | Access settings API |
|
| Settings | `Settings()` | Access settings API |
|
||||||
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||||||
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||||||
|
| Media inject | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | Inject input carrying images/audio (added in 1.1.0) |
|
||||||
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||||||
|
|
||||||
### Stage Hooks
|
### Stage Hooks
|
||||||
@ -47,10 +146,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
|||||||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ChannelDef
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ChannelDef struct {
|
||||||
|
NoMemory bool // Channel input/output skips memory computation (vector/keyword/distill), original text preserved
|
||||||
|
Cleaner func(string) string // Optional: computation layer filter (does not modify original text)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChannelDef` controls channel behavior in the memory computation layer, with the same semantics as `ToolDef.NoMemory`/`Cleaner`.
|
||||||
|
|
||||||
|
### Input Channels
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterInputChannel("qq", ChannelDef{
|
||||||
|
NoMemory: true,
|
||||||
|
Cleaner: func(text string) string { return strings.TrimSpace(text) },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### Output Channels
|
### Output Channels
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler)
|
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", ChannelDef{}, handler)
|
||||||
```
|
```
|
||||||
|
|
||||||
The handler receives three arguments:
|
The handler receives three arguments:
|
||||||
@ -85,6 +204,32 @@ Type enum values:
|
|||||||
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||||||
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||||||
|
|
||||||
|
### Multimodal Injection (added in 1.1.0)
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `InjectInputMedia(source, channel, text, blocks)` | Inject media-bearing input, asynchronous |
|
||||||
|
| `InjectInputMediaSync(source, channel, text, blocks)` | Inject media-bearing input and wait for the reply text |
|
||||||
|
| `InjectInterruptMedia(source, channel, text, blocks)` | Inject a media-bearing interrupt that can preempt current processing |
|
||||||
|
|
||||||
|
`blocks` is `[]sdk.ContentBlock`, the same type `SetToolBlocks` takes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.InjectInputMedia("myplugin", "webui", "take a look at this", []sdk.ContentBlock{{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
How this differs from `SetToolBlocks`: that one is only callable inside a tool handler and
|
||||||
|
its media reaches the model with the *next* tool message. These three let a plugin
|
||||||
|
**initiate a turn that carries media** — the media goes out with this turn's message and is
|
||||||
|
automatically stored in the media store with a memory reference attached.
|
||||||
|
|
||||||
|
`data:` URLs in the blocks are stored and deduplicated by the kernel; `http(s)` URLs are
|
||||||
|
passed to the model only and never stored (storing them would require the kernel to make
|
||||||
|
network requests, bringing timeouts, auth and SSRF into scope).
|
||||||
|
|
||||||
`source` identifies the origin, `channel` specifies the target output channel.
|
`source` identifies the origin, `channel` specifies the target output channel.
|
||||||
|
|
||||||
### Triple Extended Fields
|
### Triple Extended Fields
|
||||||
@ -94,6 +239,65 @@ The Triple data structure includes additional fields:
|
|||||||
- `Confidence` — confidence score (0.0–1.0)
|
- `Confidence` — confidence score (0.0–1.0)
|
||||||
- `SubjectType` — subject type
|
- `SubjectType` — subject type
|
||||||
- `ObjectType` — object type
|
- `ObjectType` — object type
|
||||||
|
- `SentenceText` — the original sentence (added in 1.1.0), written to the `sentences` table; media references hang off the sentence
|
||||||
|
- `MediaDigests` — associated media digests (added in 1.1.0)
|
||||||
|
|
||||||
|
### Media in Memory (added in 1.1.0)
|
||||||
|
|
||||||
|
Inside plain-text memory, media is represented as a **marker** of the form
|
||||||
|
`[<mime> <short digest>] <description>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||||
|
```
|
||||||
|
|
||||||
|
The description is the durable semantic memory (retrieval uses it); the digest is the key
|
||||||
|
back to the bytes (reverse lookup uses it). Markers are generated by the kernel — a plugin
|
||||||
|
never has to assemble one, it just **supplies the digest**.
|
||||||
|
|
||||||
|
#### Graph memory
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.Memory().Commit([]sdk.Triple{{
|
||||||
|
Subject: "palette", Relation: "contains", Object: "three-band",
|
||||||
|
MediaDigests: []string{"a1b2c3d4e5f6"}, // short digest is fine, the kernel resolves it
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `SentenceText`, the kernel uses the marker itself as the sentence — media must have
|
||||||
|
a sentence to hang off, otherwise the reference has nowhere to attach.
|
||||||
|
|
||||||
|
#### Knowledge base
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||||
|
Title: "illustrated note",
|
||||||
|
Content: "body",
|
||||||
|
}, []sdk.MediaAttachment{
|
||||||
|
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // new content, stored and deduped
|
||||||
|
{Digest: "a1b2c3d4e5f6"}, // reference existing content
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Insert` keeps its original signature; markers already present in the body are bound as
|
||||||
|
document-level references too. `Query` fills `MediaDigests` and `Attachments` (mime plus
|
||||||
|
description, **no bytes** — one query can match dozens of media items). Removing a document
|
||||||
|
releases its references.
|
||||||
|
|
||||||
|
#### Text memory
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.TextMemory().Append(sdk.TextEvent{
|
||||||
|
Role: "user", Content: "look at this",
|
||||||
|
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecentEvents` decodes markers in the body back into `Attachments`.
|
||||||
|
|
||||||
|
The media store can be disabled kernel-side (`core.memory.media.enabled=false`); all of the
|
||||||
|
above then degrades to plain-text behaviour — no errors, no panics, identical to how it
|
||||||
|
behaved before this feature shipped.
|
||||||
|
|
||||||
### ToolDef Field Reference
|
### ToolDef Field Reference
|
||||||
|
|
||||||
@ -119,16 +323,37 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
|
|
||||||
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
||||||
|
|
||||||
## plugindev Toolchain
|
## hmapdev Toolchain
|
||||||
|
|
||||||
`plugindev` provides full development workflow support:
|
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
|
||||||
|
named after that package format). 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:
|
||||||
|
|
||||||
|
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
|
||||||
|
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
|
||||||
|
> (the old directory is still honored, so installed versions are not lost).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From release assets (latest SDK release / linux amd64 shown)
|
||||||
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
|
||||||
|
chmod +x hmapdev
|
||||||
|
|
||||||
|
# Or build from source
|
||||||
|
cd tools/hmapdev && go build -o hmapdev .
|
||||||
|
```
|
||||||
|
|
||||||
|
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
|
||||||
|
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
|
||||||
|
> reproducible from source anyway.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
|
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
||||||
| `plugindev build` | Build plugin, output .hmap package |
|
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
||||||
| `plugindev clean` | Clean build artifacts |
|
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
|
||||||
| `plugindev debug` | Run plugin in local debug mode |
|
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
|
||||||
|
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
|
||||||
|
|
||||||
Supports both **Go** and **Lua** plugin languages.
|
Supports both **Go** and **Lua** plugin languages.
|
||||||
|
|
||||||
@ -142,7 +367,7 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Weather plugin",
|
"description": "Weather plugin",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.bin",
|
||||||
"tags": ["weather", "forecast"],
|
"tags": ["weather", "forecast"],
|
||||||
"targets": "linux/amd64,windows/amd64",
|
"targets": "linux/amd64,windows/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
@ -164,7 +389,7 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
| `version` | string | Version |
|
| `version` | string | Version |
|
||||||
| `description` | string | Plugin description |
|
| `description` | string | Plugin description |
|
||||||
| `author` | string | Author |
|
| `author` | string | Author |
|
||||||
| `entry` | string | Entry file (`plugin.so` / `main.lua`) |
|
| `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
|
||||||
| `tags` | string[] | Tags |
|
| `tags` | string[] | Tags |
|
||||||
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
||||||
| `outdir` | string | Output directory (default `dist`) |
|
| `outdir` | string | Output directory (default `dist`) |
|
||||||
@ -177,16 +402,36 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
`.hmap` is a ZIP archive containing:
|
`.hmap` is a ZIP archive containing:
|
||||||
|
|
||||||
- `plugin.json` — plugin metadata
|
- `plugin.json` — plugin metadata
|
||||||
- `plugin.so` — Go compiled artifact (Linux)
|
- `plugin.bin` — Go compiled artifact (single-platform build)
|
||||||
- `plugin.dll` — Go compiled artifact (Windows)
|
- `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
|
||||||
|
the one matching the current platform and renames it to `plugin.bin`
|
||||||
- `main.lua` — Lua plugin entry (for Lua plugins)
|
- `main.lua` — Lua plugin entry (for Lua plugins)
|
||||||
|
|
||||||
|
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
|
||||||
|
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
|
||||||
|
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
|
||||||
|
|
||||||
## Plugin Lifecycle
|
## Plugin Lifecycle
|
||||||
|
|
||||||
### Start & Stop
|
### Start & Stop
|
||||||
|
|
||||||
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||||||
- `Stop() error` — Plugin shutdown, release resources
|
- `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 `hmapdev` template includes an onRemove demo.
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterOnRemoveHandler(func() {
|
||||||
|
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### Auto-Restart
|
### Auto-Restart
|
||||||
|
|
||||||
@ -198,6 +443,46 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||||
|
|
||||||
|
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
|
||||||
|
|
||||||
|
| Parameter | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
|
||||||
|
| `procMaxRestarts` | `3` | Max restarts within the window |
|
||||||
|
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
|
||||||
|
|
||||||
|
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
|
||||||
|
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
|
||||||
|
This is not instant, invisible recovery — if your plugin must be back in seconds,
|
||||||
|
reconnect and rebuild your own state in `OnStart`.
|
||||||
|
|
||||||
|
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||||||
|
> external connection has been established, and that connection setup usually happens in a
|
||||||
|
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||||||
|
> concurrent. **SDK 1.1.0 locks this flag and all API fields** (`-race` reported 11 data races;
|
||||||
|
> in production this showed up as sporadic nil-dereference crashes during plugin reload). Upgrade
|
||||||
|
> if you are on anything earlier.
|
||||||
|
|
||||||
|
## Concurrency Contract for Plugin Developers
|
||||||
|
|
||||||
|
`PluginSDK` is a **shared object used by multiple goroutines**: the polling, listening and timer
|
||||||
|
callbacks you start in `Start()` all hold the same `*PluginSDK` and push messages into it, while
|
||||||
|
the kernel writes its API fields during load/reload. So:
|
||||||
|
|
||||||
|
- **Guaranteed by the SDK**: all API accessors (`Memory()`/`DocMemory()`/…), all injection methods,
|
||||||
|
`SetAutoRestart`/`AutoRestart`, `RegisterTool`/`RegisterStage`, and
|
||||||
|
`RunStopHandlers`/`RunOnRemoveHandlers` (idempotent; concurrent calls still run it once).
|
||||||
|
- **Your responsibility**: every field of `StageContext` is exported, and concurrent read/write
|
||||||
|
must hold `ctx.Lock()`/`ctx.RLock()`. Especially `ctx.Extra` — **concurrent map writes are a
|
||||||
|
fatal in Go, and `recover` cannot catch it**.
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.Extra["mykey"] = value
|
||||||
|
ctx.FinalText += "supplementary note"
|
||||||
|
ctx.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
## Restricted SDK vs Full SDK
|
## Restricted SDK vs Full SDK
|
||||||
|
|
||||||
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||||||
@ -211,36 +496,373 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
|||||||
|
|
||||||
## Example Plugins
|
## Example Plugins
|
||||||
|
|
||||||
| Plugin | Description |
|
| Plugin | Type | Description |
|
||||||
|--------|-------------|
|
|--------|------|-------------|
|
||||||
| a2a | Agent-to-Agent protocol communication |
|
| [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||||
| bili | Bilibili data fetching |
|
| [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||||
| editdoc | Document editing |
|
| [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
|
||||||
| files | File management |
|
| [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
|
||||||
| memo | Memo/notes |
|
| [ai_image](example/ai_image) | Go | AI image generation |
|
||||||
| ocr | Optical character recognition |
|
| [bili](example/bili) | Go | Bilibili video downloading |
|
||||||
| qq | QQ messaging integration |
|
| [browser](example/browser) | Go | Web search, page fetching, browser rendering |
|
||||||
| sanitizer | Content sanitization/safety filtering |
|
| [calendar](example/calendar) | Go | Calendar management |
|
||||||
| web | Web browsing and interaction |
|
| [editdoc](example/editdoc) | Go | Document editing |
|
||||||
| webfetch | Web content fetching |
|
| [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 |
|
||||||
|
|
||||||
|
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
|
||||||
|
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
|
||||||
|
that plugin binaries are **protocol-bound** to the kernel (`ProtocolVersion` + the shared-memory
|
||||||
|
magic), so shipping the toolchain without matching artifacts invites installing an old artifact —
|
||||||
|
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
|
||||||
|
match".
|
||||||
|
|
||||||
|
## 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 `hmapdev` toolchain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev 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/`
|
||||||
|
- **hmapdev template**: `hmapdev init --type remotedevice`
|
||||||
|
|
||||||
## Building & Installing
|
## Building & Installing
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs a `.hmap` package to the project directory.
|
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `hmapdev build --no-bundle` for a single-target build).
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
Via pluginmgr HTTP API:
|
Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
# Local path
|
||||||
-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"}'
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The SDK is released under the **MIT license** — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
**This permissiveness is deliberate**: the SDK is **statically linked** into your plugin
|
||||||
|
(its source ends up in the plugin binary). Under a copyleft license such as AGPL that would
|
||||||
|
force plugin authors to open-source their work; MIT exists precisely so that plugin authors
|
||||||
|
can **pick their own license** — closed-source, commercial or private — with no obligation to
|
||||||
|
contribute back and no need for any exception or commercial grant. The safety and vitality of
|
||||||
|
the third-party plugin ecosystem rest on this.
|
||||||
|
|
||||||
|
This is sound because the SDK is **fully self-contained**: `go.mod` has zero external
|
||||||
|
dependencies and `sdk/` imports only the Go standard library (`sync`), never any code from the
|
||||||
|
core repository — so the MIT grant conflicts with nothing.
|
||||||
|
|
||||||
|
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
||||||
|
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
||||||
|
(Chinese-CLIP Apache-2.0, ONNX Runtime MIT) are not part of this SDK; their full license texts
|
||||||
|
ship with the release packages under `/usr/share/doc/homeagent/licenses/`.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
204
docs/api/bridge.md
Normal file
204
docs/api/bridge.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 桥接装配点(Bridge)
|
||||||
|
|
||||||
|
以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。
|
||||||
|
|
||||||
|
### `APIRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type APIRegistrar func(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
APIRegistrar registers a plugin API for external access.
|
||||||
|
|
||||||
|
<small>`plugin.go:311`</small>
|
||||||
|
|
||||||
|
### `InputChannelRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InputChannelRegistrar func(name string, def ChannelDef) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InputChannelRegistrar registers an input channel with its memory behavior.
|
||||||
|
|
||||||
|
<small>`plugin.go:314`</small>
|
||||||
|
|
||||||
|
### `OutputChannelRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||||
|
|
||||||
|
<small>`plugin.go:317`</small>
|
||||||
|
|
||||||
|
### `OutputChannelUnregistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OutputChannelUnregistrar func(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
OutputChannelUnregistrar 注销一个输出通道。
|
||||||
|
|
||||||
|
为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
|
||||||
|
动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
|
||||||
|
必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
|
||||||
|
|
||||||
|
<small>`plugin.go:324`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetDocMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:614`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetEventSubscriber`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:638`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetIOInjector`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetIOInjector(io IOInjector)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetIOInjector sets the IO injector (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:595`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetInputChannelRegistrar`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:588`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetKnowledgeAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:620`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetLLMAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:626`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetMemoryAPI sets the memory API (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:602`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelRegistrar`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:574`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelUnregistrar`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上,桥接模板不注入。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:581`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetPluginMgrAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:645`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetSocialAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetSocialAPI(social SocialAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:632`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetTextMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:608`</small>
|
||||||
|
|
||||||
|
### `ToolRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolRegistrar registers a tool dynamically.
|
||||||
|
|
||||||
|
<small>`plugin.go:305`</small>
|
||||||
|
|
||||||
79
docs/api/builtin-only.md
Normal file
79
docs/api/builtin-only.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 仅内置插件可用的 API
|
||||||
|
|
||||||
|
这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。
|
||||||
|
|
||||||
|
判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。
|
||||||
|
|
||||||
|
### `PriorityL4`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL4
|
||||||
|
```
|
||||||
|
|
||||||
|
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:127`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Events`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:446`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetEventSubscriber`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:638`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelUnregistrar`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上,桥接模板不注入。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:581`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.UnregisterOutputChannel`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) UnregisterOutputChannel(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
|
||||||
|
|
||||||
|
<small>`plugin.go:539`</small>
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
649
docs/api/channels.md
Normal file
649
docs/api/channels.md
Normal file
@ -0,0 +1,649 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 输入 / 输出通道
|
||||||
|
|
||||||
|
通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。
|
||||||
|
|
||||||
|
## `IOInjector`
|
||||||
|
|
||||||
|
IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
|
All methods accept (source, channel) where channel is the target output channel
|
||||||
|
for routing the agent's response.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`InjectInputMedia`](#ioinjectorinjectinputmedia) | |
|
||||||
|
| [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | |
|
||||||
|
| [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | |
|
||||||
|
| [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | |
|
||||||
|
| [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 |
|
||||||
|
| [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | |
|
||||||
|
| [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | |
|
||||||
|
| [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | |
|
||||||
|
| [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | |
|
||||||
|
| [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | |
|
||||||
|
| [`InjectText`](#ioinjectorinjecttext) | |
|
||||||
|
| [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | |
|
||||||
|
| [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 |
|
||||||
|
| [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 |
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:230`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:241`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:231`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:242`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSync(source, channel, text string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||||
|
用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:226`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:240`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:232`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:243`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:221`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:239`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:222`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:223`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||||
|
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
|
||||||
|
|
||||||
|
<small>`plugin.go:238`</small>
|
||||||
|
|
||||||
|
### `IOInjector.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
|
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
|
|
||||||
|
<small>`plugin.go:229`</small>
|
||||||
|
|
||||||
|
### `CapAudio`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapAudio
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:331`</small>
|
||||||
|
|
||||||
|
### `CapFile`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapFile
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:329`</small>
|
||||||
|
|
||||||
|
### `CapImage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:330`</small>
|
||||||
|
|
||||||
|
### `CapStructured`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapStructured
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:332`</small>
|
||||||
|
|
||||||
|
### `CapText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapText
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:328`</small>
|
||||||
|
|
||||||
|
### `ChannelDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json: …
|
||||||
|
```
|
||||||
|
|
||||||
|
ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||||
|
NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||||
|
Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||||
|
ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||||
|
RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
|
||||||
|
|
||||||
|
JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||||
|
没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||||
|
那样新增字段会被静默丢掉。
|
||||||
|
|
||||||
|
<small>`plugin.go:139`</small>
|
||||||
|
|
||||||
|
### `ContextPolicyNone`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ContextPolicyNone
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
|
||||||
|
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
|
||||||
|
<small>`plugin.go:44`</small>
|
||||||
|
|
||||||
|
### `ContextPolicyPrune`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ContextPolicyPrune
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
|
||||||
|
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
|
||||||
|
<small>`plugin.go:45`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||||
|
blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||||
|
发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||||
|
的「下一轮 tool message」语义。
|
||||||
|
等价于 InjectInputMediaOpts(..., InjectOptions{})。
|
||||||
|
|
||||||
|
<small>`plugin.go:701`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:740`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||||
|
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
|
||||||
|
|
||||||
|
<small>`plugin.go:707`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:747`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputSync(source, channel, text string) 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.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:692`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:731`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||||
|
blocks 随中断消息一起发给模型。
|
||||||
|
|
||||||
|
<small>`plugin.go:764`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:756`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
|
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
|
|
||||||
|
<small>`plugin.go:673`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
|
||||||
|
|
||||||
|
中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
|
||||||
|
是否需要据此裁剪由调用方决定(默认不裁剪)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:724`</small>
|
||||||
|
|
||||||
|
### `InjectOptions`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct { NoMemory bool ContextPolicy string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 // 空串 = 默认(输入/注<><E6B3A8> …
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||||
|
|
||||||
|
零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||||
|
因此调用方只有在确实需要改变行为时才需要填它。
|
||||||
|
|
||||||
|
为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
|
||||||
|
属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
|
||||||
|
也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
|
||||||
|
|
||||||
|
NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||||
|
RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
|
||||||
|
|
||||||
|
中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||||
|
|
||||||
|
CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
|
||||||
|
|
||||||
|
空串 = 按注入的 source 查通道定义(既有行为)。
|
||||||
|
为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
|
||||||
|
而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
|
||||||
|
不指定就只能退到「按 source 查不到就不清洗」。
|
||||||
|
|
||||||
|
<small>`plugin.go:98`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectText injects a text message into the agent pipeline.
|
||||||
|
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
|
|
||||||
|
<small>`plugin.go:679`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectTextNoMemory injects a text message without generating memory.
|
||||||
|
等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:685`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:714`</small>
|
||||||
|
|
||||||
|
### `PriorityL1`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL1
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:123`</small>
|
||||||
|
|
||||||
|
### `PriorityL2`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL2
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:124`</small>
|
||||||
|
|
||||||
|
### `PriorityL3`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL3
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:125`</small>
|
||||||
|
|
||||||
|
### `PriorityL4`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL4
|
||||||
|
```
|
||||||
|
|
||||||
|
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:127`</small>
|
||||||
|
|
||||||
|
### `RecallPolicyAuto`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const RecallPolicyAuto
|
||||||
|
```
|
||||||
|
|
||||||
|
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
|
||||||
|
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
|
||||||
|
<small>`plugin.go:65`</small>
|
||||||
|
|
||||||
|
### `RecallPolicyNone`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const RecallPolicyNone
|
||||||
|
```
|
||||||
|
|
||||||
|
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
|
||||||
|
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
|
||||||
|
<small>`plugin.go:64`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterInputChannel`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterInputChannel registers an input channel with its memory behavior.
|
||||||
|
|
||||||
|
契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
|
||||||
|
注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**:
|
||||||
|
只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子";
|
||||||
|
没登记就划分会直接失败(`inputch 未注册`)。
|
||||||
|
|
||||||
|
只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名
|
||||||
|
inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
|
||||||
|
|
||||||
|
def.NoMemory: 此通道输入不参与记忆计算
|
||||||
|
def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
|
||||||
|
|
||||||
|
<small>`plugin.go:561`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterOutputChannel`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterOutputChannel registers an output channel that the output_send tool can route to.
|
||||||
|
|
||||||
|
与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__<name> 的回复发给谁);
|
||||||
|
入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
||||||
|
若该通道同时也是你的注入入口,两个都要登记。
|
||||||
|
|
||||||
|
name: channel name (e.g. "qq", "webui")。
|
||||||
|
|
||||||
|
❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
|
||||||
|
而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
|
||||||
|
而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
|
||||||
|
所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
|
||||||
|
若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
|
||||||
|
而不是把原始值直接当通道名。
|
||||||
|
caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||||
|
desc: description of the channel, expected meta format, and type enum
|
||||||
|
def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||||
|
handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:57` | `if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args…` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:57` | `s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[strin…` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
|
||||||
|
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:528`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
|
||||||
|
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
|
||||||
|
|
||||||
|
<small>`plugin.go:772`</small>
|
||||||
|
|
||||||
|
### `ValidContextPolicy`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidContextPolicy(policy string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
|
||||||
|
|
||||||
|
<small>`plugin.go:49`</small>
|
||||||
|
|
||||||
|
### `ValidRecallPolicy`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidRecallPolicy(policy string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
|
||||||
|
|
||||||
|
<small>`plugin.go:69`</small>
|
||||||
|
|
||||||
72
docs/api/constants.md
Normal file
72
docs/api/constants.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 常量与枚举
|
||||||
|
|
||||||
|
SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。
|
||||||
|
|
||||||
|
## StageOnInput 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `StageOnInput` | |
|
||||||
|
| `StagePreAction` | |
|
||||||
|
| `StagePostAction` | |
|
||||||
|
| `StageBeforeToolcall` | |
|
||||||
|
| `StageAfterToolcall` | |
|
||||||
|
| `StageBeforeOutput` | |
|
||||||
|
| `StageAfterOutput` | |
|
||||||
|
|
||||||
|
## ContextPolicyNone 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ContextPolicyNone` | |
|
||||||
|
| `ContextPolicyPrune` | |
|
||||||
|
|
||||||
|
## RecallPolicyNone 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `RecallPolicyNone` | |
|
||||||
|
| `RecallPolicyAuto` | |
|
||||||
|
|
||||||
|
## PriorityL1 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `PriorityL1` | |
|
||||||
|
| `PriorityL2` | |
|
||||||
|
| `PriorityL3` | |
|
||||||
|
| `PriorityL4` | PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 |
|
||||||
|
|
||||||
|
## EventRawInput 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `EventRawInput` | |
|
||||||
|
| `EventAgentOutput` | |
|
||||||
|
| `EventAgentLLMChain` | |
|
||||||
|
| `EventToolCall` | |
|
||||||
|
| `EventReasoning` | |
|
||||||
|
| `EventStage` | |
|
||||||
|
| `EventSystem` | |
|
||||||
|
| `EventReasoningDelta` | 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 |
|
||||||
|
| `EventContentDelta` | |
|
||||||
|
|
||||||
|
## StageScopeGlobal 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `StageScopeGlobal` | StageScopeGlobal receives all stage events (default). |
|
||||||
|
| `StageScopeOwnTools` | StageScopeOwnTools only receives events for this plugin's own tool calls |
|
||||||
|
|
||||||
|
## CapText 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `CapText` | |
|
||||||
|
| `CapFile` | |
|
||||||
|
| `CapImage` | |
|
||||||
|
| `CapAudio` | |
|
||||||
|
| `CapStructured` | |
|
||||||
|
|
||||||
69
docs/api/events.md
Normal file
69
docs/api/events.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 事件(Events)
|
||||||
|
|
||||||
|
订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。
|
||||||
|
|
||||||
|
## `EventSubscriber`
|
||||||
|
|
||||||
|
EventSubscriber allows plugins to subscribe to kernel events.
|
||||||
|
This is a restricted interface: plugins can subscribe but the kernel
|
||||||
|
controls which events are delivered.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Subscribe`](#eventsubscribersubscribe) | |
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:279`</small>
|
||||||
|
|
||||||
|
### `Event`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct { Type EventType `json:"type"` Source string `json:"source"` Payload map[string]interface{} `json:"payload"` T …
|
||||||
|
```
|
||||||
|
|
||||||
|
Event represents a system event published by the kernel.
|
||||||
|
|
||||||
|
<small>`plugin.go:265`</small>
|
||||||
|
|
||||||
|
### `EventHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EventHandler func(evt *Event)
|
||||||
|
```
|
||||||
|
|
||||||
|
EventHandler processes a system event.
|
||||||
|
|
||||||
|
<small>`plugin.go:273`</small>
|
||||||
|
|
||||||
|
### `EventType`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EventType string
|
||||||
|
```
|
||||||
|
|
||||||
|
EventType identifies the kind of system event.
|
||||||
|
|
||||||
|
<small>`plugin.go:247`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Events`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:446`</small>
|
||||||
|
|
||||||
39
docs/api/index.md
Normal file
39
docs/api/index.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# API 参考
|
||||||
|
|
||||||
|
本页所有内容**从源码生成**(`tools/apidoc`),签名与说明直接取自 `sdk/*.go` 的
|
||||||
|
文档注释。因此不存在「文档写了一套、代码是另一套」的情况——发现不一致时,
|
||||||
|
改的是源码注释,不是这里。
|
||||||
|
|
||||||
|
## 怎么找 API
|
||||||
|
|
||||||
|
<div id="api-search"></div>
|
||||||
|
|
||||||
|
用上面的搜索框可以:
|
||||||
|
|
||||||
|
- **按名称搜**:`InjectText`、`RegisterTool`、`memory.recall`
|
||||||
|
- **按描述搜**:`注册工具`、`注入`、`重载`、`崩溃`
|
||||||
|
- **按签名搜**:`(string) error`、`[]ContentBlock`
|
||||||
|
- 带 <span class="api-badge api-badge-builtin">仅内置</span>
|
||||||
|
标记的条目在**外部插件里拿不到**,多数情况下你不需要它
|
||||||
|
|
||||||
|
## 章节划分
|
||||||
|
|
||||||
|
按「你想做什么」组织,不是按 Go 的符号类别:
|
||||||
|
|
||||||
|
| 章节 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [工具(Tools)](tools.md) | 注册 LLM 可调用的工具——插件最常用的能力形态 |
|
||||||
|
| [阶段钩子(Stages)](stages.md) | 在处理管道的固定点位插入逻辑 |
|
||||||
|
| [记忆(Memory)](memory.md) | 三层记忆的读写:图 / 文档 / 文本,以及知识库 |
|
||||||
|
| [输入/输出通道](channels.md) | 与外界交换消息,以及往流水线里注入内容 |
|
||||||
|
| [配置(Settings)](settings.md) | 声明插件配置项,内核渲染到 WebUI |
|
||||||
|
| [生命周期(Lifecycle)](lifecycle.md) | 启动、停止、卸载、自动重启 |
|
||||||
|
| [事件(Events)](events.md) | 订阅内核事件 |
|
||||||
|
| [LLM 调用](llm.md) | 插件主动调用模型 |
|
||||||
|
| [常量与枚举](constants.md) | 取值枚举 |
|
||||||
|
| [桥接装配点](bridge.md) | 由 `hmapdev` 生成的运行时调用,插件业务代码不碰 |
|
||||||
|
| [仅内置插件可用](builtin-only.md) | 边界汇总——外部插件拿不到的 API 全在这里 |
|
||||||
|
|
||||||
|
!!! tip "先看「能力边界」能省很多时间"
|
||||||
|
如果你正在设计插件,先读 [能力边界](../guide/capability-boundary.md):
|
||||||
|
它说明哪些能力外部插件有、哪些没有,以及**为什么**。
|
||||||
209
docs/api/lifecycle.md
Normal file
209
docs/api/lifecycle.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 生命周期(Lifecycle)
|
||||||
|
|
||||||
|
插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。
|
||||||
|
|
||||||
|
## `Plugin`
|
||||||
|
|
||||||
|
Plugin is the interface every plugin must implement.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Name`](#pluginname) | |
|
||||||
|
| [`Start`](#pluginstart) | |
|
||||||
|
| [`Stop`](#pluginstop) | |
|
||||||
|
|
||||||
|
### `Plugin.Name`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Name() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:14`</small>
|
||||||
|
|
||||||
|
### `Plugin.Start`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:15`</small>
|
||||||
|
|
||||||
|
### `Plugin.Stop`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stop() error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:16`</small>
|
||||||
|
|
||||||
|
## `PluginMgrAPI`
|
||||||
|
|
||||||
|
PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||||
|
由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 |
|
||||||
|
| [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 |
|
||||||
|
| [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 |
|
||||||
|
|
||||||
|
### `PluginMgrAPI.IsPluginDisabled`
|
||||||
|
|
||||||
|
```go
|
||||||
|
IsPluginDisabled(name string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
IsPluginDisabled 查询插件是否被禁用。
|
||||||
|
|
||||||
|
<small>`plugin.go:290`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ListLoadedPlugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListLoadedPlugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
ListLoadedPlugins 列出已加载插件。
|
||||||
|
|
||||||
|
<small>`plugin.go:288`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ReloadOne`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ReloadOne(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ReloadOne 重载单个插件(停止后重新加载)。
|
||||||
|
|
||||||
|
<small>`plugin.go:286`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.AutoRestart`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) AutoRestart() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
AutoRestart 返回插件是否允许自动重启。
|
||||||
|
|
||||||
|
<small>`plugin.go:791`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.PluginMgr`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) PluginMgr() PluginMgrAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||||
|
May be nil if the host did not wire it.
|
||||||
|
|
||||||
|
<small>`plugin.go:653`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.PluginName`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) PluginName() string
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginName returns the name of the plugin.
|
||||||
|
|
||||||
|
<small>`plugin.go:397`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterOnRemoveHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterOnRemoveHandler(fn func())
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
|
||||||
|
注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
|
||||||
|
适用于清理外部资源、删除配置表、下线状态等删除后处理。
|
||||||
|
可注册多个;执行后清空(一次删除只执行一次)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:296` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
|
||||||
|
<small>`plugin.go:826`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterPluginAPI`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterPluginAPI(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterPluginAPI registers this plugin's API for access by other plugins.
|
||||||
|
|
||||||
|
<small>`plugin.go:502`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterStopHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterStopHandler(fn func())
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||||
|
注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||||
|
适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
|
||||||
|
可注册多个;执行后清空(进程停止前只执行一次)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
|
||||||
|
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
|
||||||
|
|
||||||
|
<small>`plugin.go:801`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RunOnRemoveHandlers`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RunOnRemoveHandlers()
|
||||||
|
```
|
||||||
|
|
||||||
|
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
|
||||||
|
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
|
||||||
|
|
||||||
|
<small>`plugin.go:837`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RunStopHandlers`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RunStopHandlers()
|
||||||
|
```
|
||||||
|
|
||||||
|
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
|
||||||
|
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
|
||||||
|
|
||||||
|
<small>`plugin.go:812`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetAutoRestart`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetAutoRestart(enabled bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetAutoRestart 设置插件崩溃后内核是否自动重启它。
|
||||||
|
默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||||
|
|
||||||
|
重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
|
||||||
|
且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
|
||||||
|
注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:47` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:47` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:25` | `s.SetAutoRestart(true)` |
|
||||||
|
|
||||||
|
<small>`plugin.go:784`</small>
|
||||||
|
|
||||||
50
docs/api/llm.md
Normal file
50
docs/api/llm.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# LLM 调用
|
||||||
|
|
||||||
|
让插件自己调用模型(而不是只等模型来调你)。
|
||||||
|
|
||||||
|
## `LLMAPI`
|
||||||
|
|
||||||
|
LLMAPI provides access to the LLM provider manager.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`CurrentSource`](#llmapicurrentsource) | |
|
||||||
|
| [`ListSources`](#llmapilistsources) | |
|
||||||
|
| [`SetSource`](#llmapisetsource) | |
|
||||||
|
|
||||||
|
### `LLMAPI.CurrentSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
CurrentSource() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:7`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.ListSources`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListSources() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:5`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.SetSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetSource(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:6`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.LLM`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) LLM() LLMAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM returns the LLM provider API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:432`</small>
|
||||||
|
|
||||||
383
docs/api/memory.md
Normal file
383
docs/api/memory.md
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 记忆(Memory)
|
||||||
|
|
||||||
|
三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。
|
||||||
|
|
||||||
|
## `DocMemoryAPI`
|
||||||
|
|
||||||
|
DocMemoryAPI provides access to the document vector store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Insert`](#docmemoryapiinsert) | |
|
||||||
|
| [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 |
|
||||||
|
| [`Query`](#docmemoryapiquery) | |
|
||||||
|
| [`Remove`](#docmemoryapiremove) | |
|
||||||
|
| [`Stats`](#docmemoryapistats) | |
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Insert`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Insert(doc *Doc) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:76`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.InsertWithMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||||
|
内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||||
|
媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
|
||||||
|
因此图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||||
|
|
||||||
|
<small>`memory.go:81`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Query`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Query(text string, topK int) []*Doc
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:75`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Remove`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Remove(id string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:82`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Stats`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stats() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:83`</small>
|
||||||
|
|
||||||
|
## `KnowledgeAPI`
|
||||||
|
|
||||||
|
KnowledgeAPI provides access to the knowledge store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Add`](#knowledgeapiadd) | |
|
||||||
|
| [`List`](#knowledgeapilist) | |
|
||||||
|
| [`Search`](#knowledgeapisearch) | |
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Add`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Add(name, content string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:6`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:7`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Search`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Search(query string, topK int) ([]*Knowledge, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:5`</small>
|
||||||
|
|
||||||
|
## `MemoryAPI`
|
||||||
|
|
||||||
|
MemoryAPI provides access to the graph memory (entity-relation store).
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Commit`](#memoryapicommit) | |
|
||||||
|
| [`Introspect`](#memoryapiintrospect) | |
|
||||||
|
| [`MergeEntities`](#memoryapimergeentities) | |
|
||||||
|
| [`Purge`](#memoryapipurge) | |
|
||||||
|
| [`Recall`](#memoryapirecall) | |
|
||||||
|
|
||||||
|
### `MemoryAPI.Commit`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Commit(triples []Triple) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:6`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Introspect`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Introspect() (map[string]interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:7`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.MergeEntities`
|
||||||
|
|
||||||
|
```go
|
||||||
|
MergeEntities(source, target string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:8`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Purge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Purge(criteria map[string]string, mode string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:9`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Recall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:5`</small>
|
||||||
|
|
||||||
|
## `SocialAPI`
|
||||||
|
|
||||||
|
SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||||
|
External plugins can query person traits and social networks but cannot modify them.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`GetNetwork`](#socialapigetnetwork) | |
|
||||||
|
| [`GetPerson`](#socialapigetperson) | |
|
||||||
|
| [`GetRelations`](#socialapigetrelations) | |
|
||||||
|
| [`GetTrait`](#socialapigettrait) | |
|
||||||
|
| [`ListPersons`](#socialapilistpersons) | |
|
||||||
|
|
||||||
|
### `SocialAPI.GetNetwork`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:104`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetPerson`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:101`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetRelations`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:103`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetTrait`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:102`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.ListPersons`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:105`</small>
|
||||||
|
|
||||||
|
## `TextMemoryAPI`
|
||||||
|
|
||||||
|
TextMemoryAPI provides access to chronological text event storage.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Append`](#textmemoryapiappend) | |
|
||||||
|
|
||||||
|
### `TextMemoryAPI.Append`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Append(evt TextEvent) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:44`</small>
|
||||||
|
|
||||||
|
### `Doc`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Doc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Score …
|
||||||
|
```
|
||||||
|
|
||||||
|
Doc represents a document in the document store.
|
||||||
|
|
||||||
|
MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
|
||||||
|
|
||||||
|
<small>`memory.go:89`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.DocMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) DocMemory() DocMemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
DocMemory returns the document memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:418`</small>
|
||||||
|
|
||||||
|
### `Entity`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Entity struct { Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
Entity represents a named entity in the knowledge graph.
|
||||||
|
|
||||||
|
<small>`memory.go:13`</small>
|
||||||
|
|
||||||
|
### `Knowledge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Knowledge struct { Name string `json:"name"` Content string `json:"content"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge represents a knowledge entry.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
|
||||||
|
|
||||||
|
<small>`knowledge.go:11`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Knowledge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Knowledge() KnowledgeAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge returns the knowledge store API (may be nil if not available).
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
|
||||||
|
|
||||||
|
<small>`plugin.go:425`</small>
|
||||||
|
|
||||||
|
### `MediaAttachment`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MediaAttachment struct { Digest string `json:"digest,omitempty"` MIME string `json:"mime,omitempty"` Data []byte `json:"data,omitempty"` Name string `json:"name,omite …
|
||||||
|
```
|
||||||
|
|
||||||
|
TextEvent represents a single text memory event.
|
||||||
|
MediaAttachment 描述一份与记忆关联的媒体。
|
||||||
|
|
||||||
|
两个方向共用一个类型:
|
||||||
|
- 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
||||||
|
- 读出(Query):内核只填 Digest/MIME,**不回 Data**——
|
||||||
|
一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
||||||
|
需要字节时拿 Digest 单独取。
|
||||||
|
|
||||||
|
刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
|
||||||
|
它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
|
||||||
|
|
||||||
|
<small>`memory.go:58`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Memory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Memory() MemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory returns the graph memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:404`</small>
|
||||||
|
|
||||||
|
### `PersonProfile`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PersonProfile struct { Name string `json:"name"` Traits map[string]string `json:"traits,omitempty"` Relations []SocialRelation `json:"relations,omitemp …
|
||||||
|
```
|
||||||
|
|
||||||
|
PersonProfile represents a person's complete profile (traits + social relations).
|
||||||
|
|
||||||
|
<small>`memory.go:109`</small>
|
||||||
|
|
||||||
|
### `Relation`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Relation struct { SourceName string `json:"source_name"` TargetName string `json:"target_name"` RelationType string `json:"relation_type"` Confidence float6 …
|
||||||
|
```
|
||||||
|
|
||||||
|
Relation represents a relationship between two entities.
|
||||||
|
|
||||||
|
<small>`memory.go:20`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Social`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Social() SocialAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Social returns the social graph API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:439`</small>
|
||||||
|
|
||||||
|
### `SocialRelation`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SocialRelation struct { Person string `json:"person"` Relation string `json:"relation"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
SocialRelation represents a social relationship between two persons.
|
||||||
|
|
||||||
|
<small>`memory.go:116`</small>
|
||||||
|
|
||||||
|
### `TextEvent`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TextEvent struct { Role string `json:"role"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` Channel …
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:65`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.TextMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) TextMemory() TextMemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
TextMemory returns the text memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:411`</small>
|
||||||
|
|
||||||
|
### `Triple`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Triple struct { Subject string `json:"subject"` Relation string `json:"relation"` Object string `json:"object"` Confidence float64 `json:"c …
|
||||||
|
```
|
||||||
|
|
||||||
|
Triple represents a subject-relation-object triple for the knowledge graph.
|
||||||
|
|
||||||
|
SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
|
||||||
|
所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
|
||||||
|
|
||||||
|
<small>`memory.go:31`</small>
|
||||||
|
|
||||||
736
docs/api/misc.md
Normal file
736
docs/api/misc.md
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 其他类型
|
||||||
|
|
||||||
|
剩余的类型与方法:`PluginSDK` 本体的访问器、`StageContext` 的并发控制,以及多模态辅助类型。没有归入上面任何一个主题,但可能仍会用到。
|
||||||
|
|
||||||
|
## `DocMemoryAPI`
|
||||||
|
|
||||||
|
DocMemoryAPI provides access to the document vector store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Insert`](#docmemoryapiinsert) | |
|
||||||
|
| [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 |
|
||||||
|
| [`Query`](#docmemoryapiquery) | |
|
||||||
|
| [`Remove`](#docmemoryapiremove) | |
|
||||||
|
| [`Stats`](#docmemoryapistats) | |
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Insert`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Insert(doc *Doc) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:76`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.InsertWithMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||||
|
内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||||
|
媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
|
||||||
|
因此图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||||
|
|
||||||
|
<small>`memory.go:81`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Query`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Query(text string, topK int) []*Doc
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:75`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Remove`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Remove(id string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:82`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Stats`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stats() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:83`</small>
|
||||||
|
|
||||||
|
## `EventSubscriber`
|
||||||
|
|
||||||
|
EventSubscriber allows plugins to subscribe to kernel events.
|
||||||
|
This is a restricted interface: plugins can subscribe but the kernel
|
||||||
|
controls which events are delivered.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Subscribe`](#eventsubscribersubscribe) | |
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:279`</small>
|
||||||
|
|
||||||
|
## `IOInjector`
|
||||||
|
|
||||||
|
IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
|
All methods accept (source, channel) where channel is the target output channel
|
||||||
|
for routing the agent's response.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`InjectInputMedia`](#ioinjectorinjectinputmedia) | |
|
||||||
|
| [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | |
|
||||||
|
| [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | |
|
||||||
|
| [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | |
|
||||||
|
| [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 |
|
||||||
|
| [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | |
|
||||||
|
| [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | |
|
||||||
|
| [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | |
|
||||||
|
| [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | |
|
||||||
|
| [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | |
|
||||||
|
| [`InjectText`](#ioinjectorinjecttext) | |
|
||||||
|
| [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | |
|
||||||
|
| [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 |
|
||||||
|
| [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 |
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:230`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:241`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:231`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:242`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSync(source, channel, text string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||||
|
用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:226`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:240`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:232`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:243`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:221`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:239`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:222`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:223`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||||
|
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
|
||||||
|
|
||||||
|
<small>`plugin.go:238`</small>
|
||||||
|
|
||||||
|
### `IOInjector.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
|
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
|
|
||||||
|
<small>`plugin.go:229`</small>
|
||||||
|
|
||||||
|
## `KnowledgeAPI`
|
||||||
|
|
||||||
|
KnowledgeAPI provides access to the knowledge store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Add`](#knowledgeapiadd) | |
|
||||||
|
| [`List`](#knowledgeapilist) | |
|
||||||
|
| [`Search`](#knowledgeapisearch) | |
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Add`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Add(name, content string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:6`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:7`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Search`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Search(query string, topK int) ([]*Knowledge, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:5`</small>
|
||||||
|
|
||||||
|
## `LLMAPI`
|
||||||
|
|
||||||
|
LLMAPI provides access to the LLM provider manager.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`CurrentSource`](#llmapicurrentsource) | |
|
||||||
|
| [`ListSources`](#llmapilistsources) | |
|
||||||
|
| [`SetSource`](#llmapisetsource) | |
|
||||||
|
|
||||||
|
### `LLMAPI.CurrentSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
CurrentSource() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:7`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.ListSources`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListSources() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:5`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.SetSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetSource(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:6`</small>
|
||||||
|
|
||||||
|
## `MemoryAPI`
|
||||||
|
|
||||||
|
MemoryAPI provides access to the graph memory (entity-relation store).
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Commit`](#memoryapicommit) | |
|
||||||
|
| [`Introspect`](#memoryapiintrospect) | |
|
||||||
|
| [`MergeEntities`](#memoryapimergeentities) | |
|
||||||
|
| [`Purge`](#memoryapipurge) | |
|
||||||
|
| [`Recall`](#memoryapirecall) | |
|
||||||
|
|
||||||
|
### `MemoryAPI.Commit`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Commit(triples []Triple) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:6`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Introspect`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Introspect() (map[string]interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:7`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.MergeEntities`
|
||||||
|
|
||||||
|
```go
|
||||||
|
MergeEntities(source, target string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:8`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Purge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Purge(criteria map[string]string, mode string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:9`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Recall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:5`</small>
|
||||||
|
|
||||||
|
## `Plugin`
|
||||||
|
|
||||||
|
Plugin is the interface every plugin must implement.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Name`](#pluginname) | |
|
||||||
|
| [`Start`](#pluginstart) | |
|
||||||
|
| [`Stop`](#pluginstop) | |
|
||||||
|
|
||||||
|
### `Plugin.Name`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Name() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:14`</small>
|
||||||
|
|
||||||
|
### `Plugin.Start`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:15`</small>
|
||||||
|
|
||||||
|
### `Plugin.Stop`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stop() error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:16`</small>
|
||||||
|
|
||||||
|
## `PluginMgrAPI`
|
||||||
|
|
||||||
|
PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||||
|
由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 |
|
||||||
|
| [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 |
|
||||||
|
| [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 |
|
||||||
|
|
||||||
|
### `PluginMgrAPI.IsPluginDisabled`
|
||||||
|
|
||||||
|
```go
|
||||||
|
IsPluginDisabled(name string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
IsPluginDisabled 查询插件是否被禁用。
|
||||||
|
|
||||||
|
<small>`plugin.go:290`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ListLoadedPlugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListLoadedPlugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
ListLoadedPlugins 列出已加载插件。
|
||||||
|
|
||||||
|
<small>`plugin.go:288`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ReloadOne`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ReloadOne(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ReloadOne 重载单个插件(停止后重新加载)。
|
||||||
|
|
||||||
|
<small>`plugin.go:286`</small>
|
||||||
|
|
||||||
|
## `SettingsAPI`
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): |
|
||||||
|
| [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. |
|
||||||
|
| [`Dump`](#settingsapidump) | Dump returns all config values. |
|
||||||
|
| [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_<name> table). |
|
||||||
|
| [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. |
|
||||||
|
| [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. |
|
||||||
|
| [`List`](#settingsapilist) | List returns all keys matching the given prefix. |
|
||||||
|
| [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. |
|
||||||
|
| [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. |
|
||||||
|
| [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. |
|
||||||
|
| [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. |
|
||||||
|
| [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. |
|
||||||
|
| [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. |
|
||||||
|
| [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. |
|
||||||
|
|
||||||
|
### `SettingsAPI.DataDir`
|
||||||
|
|
||||||
|
```go
|
||||||
|
DataDir() string
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<small>`settings.go:25`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Defs`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Defs(prefix string) []*ConfigDef
|
||||||
|
```
|
||||||
|
|
||||||
|
Defs returns config definitions matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:40`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Dump`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Dump() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dump returns all config values.
|
||||||
|
|
||||||
|
<small>`settings.go:43`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Get`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Get(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Get reads the plugin's own config value (config_<name> table).
|
||||||
|
|
||||||
|
<small>`settings.go:5`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetCore(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetCore reads the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:14`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetPlugin reads another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:28`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
List returns all keys matching the given prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:11`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListCore(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListCore lists core config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:20`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPlugin(plugin, prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListPlugin lists another plugin's config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:34`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Plugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Plugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins returns a list of all plugin config namespaces.
|
||||||
|
|
||||||
|
<small>`settings.go:46`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.RegisterDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
RegisterDef(def ConfigDef)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterDef registers a config definition for UI display.
|
||||||
|
|
||||||
|
<small>`settings.go:37`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Set`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Set(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Set writes a config value to the plugin's own config table.
|
||||||
|
|
||||||
|
<small>`settings.go:8`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetCore(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetCore writes to the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:17`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetPlugin(plugin, key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPlugin writes to another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:31`</small>
|
||||||
|
|
||||||
|
## `SocialAPI`
|
||||||
|
|
||||||
|
SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||||
|
External plugins can query person traits and social networks but cannot modify them.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`GetNetwork`](#socialapigetnetwork) | |
|
||||||
|
| [`GetPerson`](#socialapigetperson) | |
|
||||||
|
| [`GetRelations`](#socialapigetrelations) | |
|
||||||
|
| [`GetTrait`](#socialapigettrait) | |
|
||||||
|
| [`ListPersons`](#socialapilistpersons) | |
|
||||||
|
|
||||||
|
### `SocialAPI.GetNetwork`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:104`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetPerson`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:101`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetRelations`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:103`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetTrait`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:102`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.ListPersons`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:105`</small>
|
||||||
|
|
||||||
|
## `TextMemoryAPI`
|
||||||
|
|
||||||
|
TextMemoryAPI provides access to chronological text event storage.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Append`](#textmemoryapiappend) | |
|
||||||
|
|
||||||
|
### `TextMemoryAPI.Append`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Append(evt TextEvent) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:44`</small>
|
||||||
|
|
||||||
|
### `AudioURL`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AudioURL struct { URL string `json:"url"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:862`</small>
|
||||||
|
|
||||||
|
### `ImageURL`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ImageURL struct { URL string `json:"url"` Detail string `json:"detail,omitempty"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:857`</small>
|
||||||
|
|
||||||
|
### `MemItem`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MemItem struct { Role string `json:"role"` Content string `json:"content"` Score float64 `json:"score"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
MemItem represents a memory item in stage context.
|
||||||
|
|
||||||
|
<small>`plugin.go:179`</small>
|
||||||
|
|
||||||
|
### `PluginSDK`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PluginSDK struct { name string regTool ToolRegistrar regStage StageRegistrar regAPI APIRegistrar regOutput OutputChannelRegistrar …
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginSDK is the main API surface provided to plugins at runtime.
|
||||||
|
It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
|
||||||
|
|
||||||
|
<small>`plugin.go:337`</small>
|
||||||
|
|
||||||
|
### `SDKVersion`
|
||||||
|
|
||||||
|
```go
|
||||||
|
var SDKVersion
|
||||||
|
```
|
||||||
|
|
||||||
|
SDKVersion 是对外暴露的 SDK 版本号。
|
||||||
|
|
||||||
|
<small>`plugin.go:10`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.UnregisterOutputChannel`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) UnregisterOutputChannel(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
|
||||||
|
|
||||||
|
<small>`plugin.go:539`</small>
|
||||||
|
|
||||||
197
docs/api/settings.md
Normal file
197
docs/api/settings.md
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 配置(Settings)
|
||||||
|
|
||||||
|
声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。
|
||||||
|
|
||||||
|
## `SettingsAPI`
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): |
|
||||||
|
| [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. |
|
||||||
|
| [`Dump`](#settingsapidump) | Dump returns all config values. |
|
||||||
|
| [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_<name> table). |
|
||||||
|
| [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. |
|
||||||
|
| [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. |
|
||||||
|
| [`List`](#settingsapilist) | List returns all keys matching the given prefix. |
|
||||||
|
| [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. |
|
||||||
|
| [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. |
|
||||||
|
| [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. |
|
||||||
|
| [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. |
|
||||||
|
| [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. |
|
||||||
|
| [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. |
|
||||||
|
| [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. |
|
||||||
|
|
||||||
|
### `SettingsAPI.DataDir`
|
||||||
|
|
||||||
|
```go
|
||||||
|
DataDir() string
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<small>`settings.go:25`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Defs`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Defs(prefix string) []*ConfigDef
|
||||||
|
```
|
||||||
|
|
||||||
|
Defs returns config definitions matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:40`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Dump`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Dump() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dump returns all config values.
|
||||||
|
|
||||||
|
<small>`settings.go:43`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Get`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Get(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Get reads the plugin's own config value (config_<name> table).
|
||||||
|
|
||||||
|
<small>`settings.go:5`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetCore(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetCore reads the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:14`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetPlugin reads another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:28`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
List returns all keys matching the given prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:11`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListCore(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListCore lists core config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:20`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPlugin(plugin, prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListPlugin lists another plugin's config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:34`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Plugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Plugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins returns a list of all plugin config namespaces.
|
||||||
|
|
||||||
|
<small>`settings.go:46`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.RegisterDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
RegisterDef(def ConfigDef)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterDef registers a config definition for UI display.
|
||||||
|
|
||||||
|
<small>`settings.go:37`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Set`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Set(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Set writes a config value to the plugin's own config table.
|
||||||
|
|
||||||
|
<small>`settings.go:8`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetCore(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetCore writes to the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:17`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetPlugin(plugin, key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPlugin writes to another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:31`</small>
|
||||||
|
|
||||||
|
### `ConfigDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigDef struct { Key string `json:"key"` Default interface{} `json:"default,omitempty"` Type string `json:"type"` DisplayName string …
|
||||||
|
```
|
||||||
|
|
||||||
|
ConfigDef describes a configuration field for the WebUI.
|
||||||
|
|
||||||
|
<small>`settings.go:50`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Settings`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Settings() SettingsAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings returns the settings API for reading/writing plugin configuration.
|
||||||
|
sett 在 New 时一次性写入且无 setter,故不需要加锁。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:68` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:63` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:29` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:401`</small>
|
||||||
|
|
||||||
154
docs/api/stages.md
Normal file
154
docs/api/stages.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 阶段钩子(Stages)
|
||||||
|
|
||||||
|
在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。
|
||||||
|
|
||||||
|
### `StageContext.IsResponded`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) IsResponded() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:172`</small>
|
||||||
|
|
||||||
|
### `StageContext.Lock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) Lock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:158` | `p.sessMu.Lock()` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:128` | `p.srvMu.Lock()` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:433` | `p.mu.Lock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:170`</small>
|
||||||
|
|
||||||
|
### `StageContext.RLock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) RLock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:267` | `p.mu.RLock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:649` | `p.mu.RLock()` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:168`</small>
|
||||||
|
|
||||||
|
### `StageContext.RUnlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) RUnlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:273` | `p.mu.RUnlock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:650` | `defer p.mu.RUnlock()` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:169`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterStage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterStage registers a handler for a pipeline stage.
|
||||||
|
|
||||||
|
scope: StageScopeGlobal (default) — receives all stage events.
|
||||||
|
StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:152` | `s.RegisterStage(sdk.StagePreAction, p.stagePreAction)` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:719` | `s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)` |
|
||||||
|
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
|
||||||
|
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
|
||||||
|
|
||||||
|
<small>`plugin.go:467`</small>
|
||||||
|
|
||||||
|
### `Stage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Stage string
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage represents a point in the message processing pipeline.
|
||||||
|
|
||||||
|
<small>`plugin.go:26`</small>
|
||||||
|
|
||||||
|
### `StageContext`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageContext struct { mu sync.RWMutex RawMessage string UserID string GroupID string ContextMsgs []map[string]interface{} L …
|
||||||
|
```
|
||||||
|
|
||||||
|
StageContext provides context for stage handlers.
|
||||||
|
|
||||||
|
<small>`plugin.go:148`</small>
|
||||||
|
|
||||||
|
### `StageHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageHandler func(ctx *StageContext) error
|
||||||
|
```
|
||||||
|
|
||||||
|
StageHandler is a function that handles a pipeline stage event.
|
||||||
|
|
||||||
|
<small>`plugin.go:23`</small>
|
||||||
|
|
||||||
|
### `StageRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
StageRegistrar registers a stage handler.
|
||||||
|
|
||||||
|
<small>`plugin.go:308`</small>
|
||||||
|
|
||||||
|
### `StageScope`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageScope int
|
||||||
|
```
|
||||||
|
|
||||||
|
StageScope controls which events a stage handler receives.
|
||||||
|
|
||||||
|
<small>`plugin.go:294`</small>
|
||||||
|
|
||||||
|
### `StageContext.Unlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:164` | `p.sessMu.Unlock()` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:129` | `defer p.srvMu.Unlock()` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:523` | `p.mu.Unlock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:171`</small>
|
||||||
|
|
||||||
77
docs/api/tools.md
Normal file
77
docs/api/tools.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 工具(Tools)
|
||||||
|
|
||||||
|
注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。
|
||||||
|
|
||||||
|
### `ContentBlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` AudioURL *AudioURL `jso …
|
||||||
|
```
|
||||||
|
|
||||||
|
ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。
|
||||||
|
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
|
||||||
|
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
|
||||||
|
|
||||||
|
<small>`plugin.go:850`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterTool`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterTool registers a tool that the LLM can call.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:76` | `s.RegisterTool(tp+"a2a_query", sdk.ToolDef{` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:70` | `s.RegisterTool(tp+"acp_query", sdk.ToolDef{` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:47` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:453`</small>
|
||||||
|
|
||||||
|
### `ToolCall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolCall represents a model's request to call a tool.
|
||||||
|
|
||||||
|
<small>`plugin.go:186`</small>
|
||||||
|
|
||||||
|
### `ToolDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Description string …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolDef describes a tool that the plugin exposes.
|
||||||
|
|
||||||
|
<small>`plugin.go:203`</small>
|
||||||
|
|
||||||
|
### `ToolHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolHandler is a function that handles a tool call.
|
||||||
|
|
||||||
|
<small>`plugin.go:20`</small>
|
||||||
|
|
||||||
|
### `ToolResult`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Success bool `json:"suc …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolResult represents the result of a tool call.
|
||||||
|
|
||||||
|
<small>`plugin.go:194`</small>
|
||||||
|
|
||||||
2888
docs/assets/api-index.json
Normal file
2888
docs/assets/api-index.json
Normal file
File diff suppressed because it is too large
Load Diff
27
docs/assets/logo-mark.svg
Normal file
27
docs/assets/logo-mark.svg
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||||
|
|
||||||
|
<g transform="translate(200,200)">
|
||||||
|
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
|
||||||
|
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
|
||||||
|
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
|
||||||
|
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
|
||||||
|
|
||||||
|
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
|
||||||
|
|
||||||
|
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
28
docs/assets/logo.svg
Normal file
28
docs/assets/logo.svg
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||||
|
<rect width="400" height="400" fill="#F8FAFC"/>
|
||||||
|
|
||||||
|
<g transform="translate(200,200)">
|
||||||
|
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
|
||||||
|
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
|
||||||
|
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
|
||||||
|
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
|
||||||
|
|
||||||
|
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
|
||||||
|
|
||||||
|
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
60
docs/examples/index.md
Normal file
60
docs/examples/index.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 示例插件
|
||||||
|
|
||||||
|
SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。
|
||||||
|
|
||||||
|
每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。
|
||||||
|
|
||||||
|
## `a2a`
|
||||||
|
|
||||||
|
用到的 API:`InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `acp`
|
||||||
|
|
||||||
|
用到的 API:`InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `ai_image`
|
||||||
|
|
||||||
|
用到的 API:`RegisterTool` · `SetAutoRestart` · `Settings`
|
||||||
|
|
||||||
|
## `bili`
|
||||||
|
|
||||||
|
用到的 API:`RegisterTool` · `SetAutoRestart` · `Settings`
|
||||||
|
|
||||||
|
## `browser`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock`
|
||||||
|
|
||||||
|
## `calendar`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOnRemoveHandler` · `RegisterStopHandler` · `Unlock`
|
||||||
|
|
||||||
|
## `deepsearch`
|
||||||
|
|
||||||
|
用到的 API:`RegisterStopHandler`
|
||||||
|
|
||||||
|
## `memo`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOnRemoveHandler` · `RegisterStage`
|
||||||
|
|
||||||
|
## `qq`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOutputChannel` · `RegisterStage`
|
||||||
|
|
||||||
|
## `recoverydiag`
|
||||||
|
|
||||||
|
用到的 API:`Knowledge`
|
||||||
|
|
||||||
|
## `rss`
|
||||||
|
|
||||||
|
用到的 API:`RegisterOnRemoveHandler`
|
||||||
|
|
||||||
|
## `sanitizer`
|
||||||
|
|
||||||
|
用到的 API:`RegisterStage`
|
||||||
|
|
||||||
|
## `weather`
|
||||||
|
|
||||||
|
用到的 API:`RegisterOutputChannel` · `RegisterStage`
|
||||||
|
|
||||||
75
docs/guide/capability-boundary.md
Normal file
75
docs/guide/capability-boundary.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# 能力边界:哪些 API 外部插件能用
|
||||||
|
|
||||||
|
HomeAgent 有两类插件:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 分发 |
|
||||||
|
|---|---|---|
|
||||||
|
| **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** |
|
||||||
|
| **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 |
|
||||||
|
|
||||||
|
SDK 包是**同一个** `gitcode.com/JianFeeeee/homeagent-sdk/sdk`,但两类插件拿到的
|
||||||
|
**能力不同**:外部插件跑在独立进程里,由内核通过桥接注入能力(IPC,不是共享内存里的直接调用)。
|
||||||
|
|
||||||
|
本页说明边界在哪、为什么,以及**怎么在写代码前就知道某个 API 是否可用**。
|
||||||
|
|
||||||
|
## 一句话规则
|
||||||
|
|
||||||
|
> **公开 SDK 包里声明的符号,不等于外部插件拿得到。**
|
||||||
|
|
||||||
|
原因是:有些能力只有进程内的内置插件才可能拥有(比如直接读事件发布通道、
|
||||||
|
直接注入到内核 IO 层)。外部插件通过桥接运行时拿到的是一份**受注入的能力集合**。
|
||||||
|
|
||||||
|
## 外部插件**不可用**的 API
|
||||||
|
|
||||||
|
这些 API 在公开包里存在,但在外部插件路径上拿不到。文档里每条都带
|
||||||
|
<span class="api-badge api-badge-builtin">仅内置</span> 标记,
|
||||||
|
完整清单见 [仅内置插件可用](../api/builtin-only.md)。
|
||||||
|
|
||||||
|
| API | 外部插件的实际情况 | 该用什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sdk.PluginSDK.Events()` | **恒为 nil**。桥接运行时不注入 event subscriber(`SetEventSubscriber` 全仓无调用点) | 桥接运行时已按你的声明完成 `events.subscribe`;Lua 插件用 `sdk.events.subscribe` |
|
||||||
|
| `sdk.PluginSDK.SetEventSubscriber` | 无人调用 | 同上 |
|
||||||
|
| `UnregisterOutputChannel` | 桥接只注入 registrar、**不注入 unregistrar**,调用是**静默无效**(返回 nil,不报错也不注销) | `RegisterOutputChannel` 可用;注销需重载插件 |
|
||||||
|
| `SocialAPI` 的写操作 | 公开接口只有 6 个**只读**方法 | 读用 `s.GetPerson` 等;写需内置插件 |
|
||||||
|
| `EventSubscriber.Publish` | 公开接口**刻意只有 Subscribe**,没有 Publish | 只订阅 |
|
||||||
|
| `PriorityL4` | 声明会被内核**夹到 L3** | 用 L1–L3 |
|
||||||
|
| `RegisterChannel` / `ListChannels` / `OutputChan` / `InjectInput` / `InjectInterrupt` | 只存在于内核内部 SDK | `RegisterInputChannel` / `RegisterOutputChannel` / `InjectText` 等公开方法 |
|
||||||
|
| `PluginMgr()` 的完整能力 | 公开 `PluginMgrAPI` **只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` / `IsPluginDisabled`) | 就这 3 个;`ReloadPlugins`/`Disable`/`Remove` 属内部接口 |
|
||||||
|
|
||||||
|
!!! warning "两处常见的文档错误(本站已更正)"
|
||||||
|
1. **`PluginMgr()` 不是「仅内置可用」**。桥接模板显式注入了它
|
||||||
|
(`base.SetPluginMgrAPI(procPluginMgr{})`),公开 `PluginMgrAPI` 也注明
|
||||||
|
「外部插件可调用」。真正的区别是**方法数量**:公开面 3 个,内部面 9 个。
|
||||||
|
容易混淆是因为两个包里有**同名但不同**的接口:
|
||||||
|
`sdk.PluginMgrAPI`(3 方法)与 `internal/sdk.PluginManager`(9 方法)。
|
||||||
|
2. **`Events()` 恒为 nil 这件事以前没写清**。旧文档把 `Events()` 当作
|
||||||
|
可用的订阅入口,但桥接运行时不注入 subscriber。外部插件的事件订阅
|
||||||
|
实际由生成的运行时通过 `events.subscribe` 完成。
|
||||||
|
|
||||||
|
## 判定依据来自哪里
|
||||||
|
|
||||||
|
本站的「仅内置」标记不是猜的,逐条来自:
|
||||||
|
|
||||||
|
1. **`tools/hmapdev/templates/proc_main.go.tmpl`** —— 外部插件运行时**实际注入**
|
||||||
|
哪些能力,看 `buildPluginSDK()` 里的 `base.Set*` 调用。
|
||||||
|
2. **`internal/sdk`** —— 内置插件用的完整接口,与公开包对照。
|
||||||
|
3. **内核 RPC 协议表**(`internal/plugin/proc/protocol.go`)—— 外部插件**能发哪些请求**。
|
||||||
|
|
||||||
|
每条裁定的具体依据写在该 API 的告警框里,可以直接核对。
|
||||||
|
|
||||||
|
## 怎么快速确认
|
||||||
|
|
||||||
|
- 用 [API 搜索](../api/index.md) 搜 API 名或功能描述,带
|
||||||
|
<span class="api-badge api-badge-builtin">仅内置</span> 的就是外部不可用
|
||||||
|
- 直接看 [仅内置插件可用](../api/builtin-only.md) 汇总页
|
||||||
|
- 拿不准时,**读 `example/` 下的示例插件** —— 它们全是外部插件,
|
||||||
|
能被它们编译通过的写法,外部就一定可用
|
||||||
|
|
||||||
|
## 为什么这样设计
|
||||||
|
|
||||||
|
不是为了限制,而是**IPC 边界决定了能力边界**:外部插件跑在独立进程里,
|
||||||
|
内核只能通过显式的注入点把能力交过去。凡是需要「持有内核内部数据结构」
|
||||||
|
的能力(事件发布通道、IO 通道、插件注册表全量操作),进程外都无法安全暴露。
|
||||||
|
|
||||||
|
这套边界同时带来好处:插件崩溃不会带崩内核(进程隔离),
|
||||||
|
以及**插件可以闭源**(SDK 是 MIT,见[首页](../index.md#_3))。
|
||||||
111
docs/guide/first-lua-plugin.md
Normal file
111
docs/guide/first-lua-plugin.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# 第一个 Lua 插件
|
||||||
|
|
||||||
|
Lua 插件适合**轻量、快速原型**:不需要 Go 编译环境,改完重启内核即可生效。
|
||||||
|
但它有一个必须理解的限制 —— 执行模型是**被动回调**。
|
||||||
|
|
||||||
|
## 执行模型(先读这段)
|
||||||
|
|
||||||
|
Lua 插件跑在内核进程内的 gopher-lua 解释器里(单 Lua 状态 + 互斥锁):
|
||||||
|
|
||||||
|
- **被动回调**:`main.lua` 只在加载时执行一次。此后工具、阶段钩子、
|
||||||
|
输入输出通道全部由内核事件驱动回调你的 Lua 函数。**插件不能自己启动后台任务。**
|
||||||
|
- **没有并发**:Lua 侧没有 goroutine、协程调度,也没有 `os` / `io` 库和 socket 监听。
|
||||||
|
唯一主动出站通道是 `sdk.http.get/post`(同步请求)。
|
||||||
|
- **任何阻塞循环都会持锁卡死该插件的全部调用。**
|
||||||
|
|
||||||
|
!!! warning "要常驻服务就用 Go 插件"
|
||||||
|
需要监听端口、后台轮询、定时任务的,请用 [Go 插件](first-plugin.md)
|
||||||
|
(可自行启动 goroutine)。Lua 侧的等价做法是**事件驱动**:把逻辑挂在
|
||||||
|
工具、阶段钩子或通道回调上。
|
||||||
|
|
||||||
|
## 生成工程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev init myluaplugin --lua
|
||||||
|
cd myluaplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
myluaplugin/
|
||||||
|
├── plg.json — entry: "main.lua", targets: "lua"
|
||||||
|
├── main.lua — 插件实现
|
||||||
|
├── sdk.lua — SDK 模拟层(支持独立测试)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一个完整的插件
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- main.lua
|
||||||
|
local plugin = {
|
||||||
|
name = "myluaplugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin.start(sdk)
|
||||||
|
sdk.log("info", "myluaplugin starting...")
|
||||||
|
|
||||||
|
sdk.register_tool("myluaplugin_hello", {
|
||||||
|
description = "向指定的人打招呼",
|
||||||
|
parameters = {
|
||||||
|
type = "object",
|
||||||
|
properties = {
|
||||||
|
who = { type = "string", description = "要打招呼的对象" }
|
||||||
|
},
|
||||||
|
required = { "who" }
|
||||||
|
}
|
||||||
|
}, function(args)
|
||||||
|
return { content = "hello, " .. (args.who or "world") .. "!" }
|
||||||
|
end)
|
||||||
|
|
||||||
|
sdk.log("info", "myluaplugin started")
|
||||||
|
end
|
||||||
|
|
||||||
|
function plugin.stop()
|
||||||
|
sdk.log("info", "myluaplugin stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地测试
|
||||||
|
|
||||||
|
`sdk.lua` 是纯 Lua 的 SDK 模拟实现,可以直接用解释器跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua
|
||||||
|
# [lua-plugin] info: myluaplugin starting...
|
||||||
|
# [lua-plugin] register_tool: myluaplugin_hello
|
||||||
|
# [lua-plugin] info: myluaplugin started
|
||||||
|
```
|
||||||
|
|
||||||
|
在内核里运行时,`sdk.*` 由 Go 层注入,`sdk.lua` 里所有 `-- !impl` 标记的函数
|
||||||
|
会被替换成真实实现。
|
||||||
|
|
||||||
|
## API 约定的两点
|
||||||
|
|
||||||
|
- **注册类函数调用即时报错**(抛 Lua error)—— 注册失败不会静默。
|
||||||
|
- **数据类函数统一返回 `(result, err)`**,`err` 为 nil 表示成功。
|
||||||
|
核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||||
|
|
||||||
|
Lua 侧的 `sdk.*` 能力与外部 Go 插件对齐至 SDK 1.3.0(需内核 1.4.0+)。
|
||||||
|
|
||||||
|
!!! note "历史提醒"
|
||||||
|
1.1–1.3 期间,媒体 / 注入标志位 / 优先级能力只在 Go 侧有,Lua 侧静默缺失。
|
||||||
|
现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住
|
||||||
|
「`sdk.lua` 承诺的每个函数都有运行时绑定」。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # → dist/myluaplugin_lua.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
Lua 插件直接打包源码,不经过编译。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [能力边界](capability-boundary.md) —— Lua 与 Go 外部插件的能力面一致
|
||||||
|
- [打包与发布](packaging.md)
|
||||||
|
- [示例](../examples/index.md) —— `example/luademo` 是 Lua 版参考实现
|
||||||
162
docs/guide/first-plugin.md
Normal file
162
docs/guide/first-plugin.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# 第一个 Go 插件
|
||||||
|
|
||||||
|
以下是一个**能直接跑起来**的最小插件:注册一个工具、声明一项配置、处理停止与卸载。
|
||||||
|
|
||||||
|
## 1. 生成工程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev init myplugin
|
||||||
|
cd myplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
myplugin/
|
||||||
|
├── plg.json — 插件元信息(名称、版本、入口、目标平台)
|
||||||
|
├── plugin.go — 插件实现
|
||||||
|
├── go.mod — 模块定义
|
||||||
|
├── README.md
|
||||||
|
└── thirdpart/ — 外部源码存放目录(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
`hmapdev build` 时会在构建目录自动生成子进程运行时(`z_proc_gen.go` 等),
|
||||||
|
**不需要手工创建,也不要提交**。
|
||||||
|
|
||||||
|
## 2. 插件实现
|
||||||
|
|
||||||
|
插件的全部契约是一个 `Plugin` 接口([API 参考](../api/lifecycle.md#plugin)):
|
||||||
|
|
||||||
|
| 方法 | 何时调用 |
|
||||||
|
|---|---|
|
||||||
|
| `Name() string` | 内核需要标识这个插件时 |
|
||||||
|
| `Start(*sdk.PluginSDK) error` | 插件加载后。**在这里注册工具、通道、配置** |
|
||||||
|
| `Stop() error` | 插件停止时(重载、禁用、内核退出都会触发) |
|
||||||
|
|
||||||
|
再加一个工厂函数。**名字必须是 `NewPluginFactory`** —— 生成的运行时按这个名字调用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "不要写成 `NewPlugin`"
|
||||||
|
生成的子进程运行时调用的入口是 `NewPluginFactory`。仓库里有 3 个早期示例
|
||||||
|
同时保留了两个名字(`NewPlugin` 只是遗留别名),但新插件只写
|
||||||
|
`NewPluginFactory` 即可。写错名字的后果是**编译能过、加载时找不到入口**。
|
||||||
|
|
||||||
|
## 3. 一个完整的例子
|
||||||
|
|
||||||
|
这是一个「打招呼」工具,带一项配置:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
|
||||||
|
// ① 声明配置项:内核会把它渲染到 WebUI 设置页
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.myplugin.greeting",
|
||||||
|
Default: "hello",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "问候语",
|
||||||
|
Description: "打招呼时使用的前缀",
|
||||||
|
Category: "myplugin",
|
||||||
|
})
|
||||||
|
|
||||||
|
// ② 注册工具:模型看到 Description 后决定是否调用
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello",
|
||||||
|
Description: "向指定的人打招呼",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"who": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要打招呼的对象",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"who"},
|
||||||
|
},
|
||||||
|
}, p.handleHello)
|
||||||
|
|
||||||
|
// ③ 卸载(插件被删除)前清理自己产生的数据。
|
||||||
|
// 注意与 Stop 的区别:Stop 在每次重载时也会触发。
|
||||||
|
s.RegisterOnRemoveHandler(func() {
|
||||||
|
fmt.Printf("[%s] 清理数据\n", p.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
who, _ := args["who"].(string)
|
||||||
|
|
||||||
|
greeting := "hello"
|
||||||
|
if v, err := p.sdk.Settings().Get("plugin.myplugin.greeting"); err == nil && v != "" {
|
||||||
|
greeting = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("%s, %s!", greeting, who),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 工具返回值的两条约定
|
||||||
|
|
||||||
|
`ToolHandler` 返回 `(interface{}, error)`,模型侧看到的是一条 tool message:
|
||||||
|
|
||||||
|
- **正常结果**:返回一个 map,把要展示给模型的文本放在 `content` 字段。
|
||||||
|
未识别的字段也会一并传给模型,可以放结构化数据。
|
||||||
|
- **业务失败**:返回 `map[string]interface{}{"isError": true, "content": "原因"}`
|
||||||
|
**并返回 nil error**。这样模型能看到失败原因并自行调整;
|
||||||
|
若返回 Go 的 `error`,那是**工具调用本身出错**,语义不同。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 构建与安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # 默认产出多平台 bundle
|
||||||
|
# → dist/myplugin_bundle.hmap
|
||||||
|
|
||||||
|
hmapdev build --no-bundle # 只构建当前平台
|
||||||
|
# → dist/myplugin_linux_amd64.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装到内核:在 WebUI 的插件管理页上传 `.hmap`,或从 URL / 本地路径安装。
|
||||||
|
详见 [打包与发布](packaging.md)。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [能力边界](capability-boundary.md) —— 哪些 API 外部插件能用
|
||||||
|
- [工具(Tools)](../api/tools.md) —— `ToolDef` 的完整字段
|
||||||
|
- [记忆(Memory)](../api/memory.md) —— 让插件读写长期记忆
|
||||||
|
- [示例插件](../examples/index.md) —— `example/memo` 是个完整的可读实现
|
||||||
62
docs/guide/getting-started.md
Normal file
62
docs/guide/getting-started.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# 环境与工具链
|
||||||
|
|
||||||
|
`hmapdev` 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
|
||||||
|
最终产出 `.hmap` 插件包(工具名即取自这个包格式)。
|
||||||
|
|
||||||
|
!!! note "改名说明"
|
||||||
|
1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||||
|
`~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
|
(旧目录会自动继续沿用)。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
从源码构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitcode.com/JianFeeeee/homeagent-sdk
|
||||||
|
cd homeagent-sdk/tools/hmapdev
|
||||||
|
go build -o hmapdev
|
||||||
|
# 把 hmapdev 放进 PATH,或直接用 ./hmapdev
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以从 SDK 的 release 附件下载预编译二进制(`hmapdev_linux_amd64` 等)。
|
||||||
|
|
||||||
|
## SDK 版本管理
|
||||||
|
|
||||||
|
`hmapdev` 会维护一份本地 SDK 存储,`init` 时按 `plg.json` 里的 `sdk` 字段
|
||||||
|
选择版本。两者**必须**一致,否则编译出的插件与内核协议可能错配。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk list # 已安装的 SDK 版本
|
||||||
|
hmapdev sdk current # 当前使用的版本
|
||||||
|
hmapdev sdk latest # 最新可用版本
|
||||||
|
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||||
|
hmapdev sdk use v1.2.0 # 切换版本
|
||||||
|
hmapdev sdk path # 当前 SDK 路径
|
||||||
|
```
|
||||||
|
|
||||||
|
存储在 `~/.homeagent/hmapdev/sdk/<version>/`。
|
||||||
|
|
||||||
|
!!! warning "版本未命中会**明确报错**"
|
||||||
|
`plg.json` 声明的 `sdk` 版本若不在本地存储里,`hmapdev` 不会退回某个默认版本,
|
||||||
|
而是报错并让你先 `hmapdev sdk install`。这是有意的:静默降级会产出与内核
|
||||||
|
协议不匹配的插件,那种失败要到运行时才暴露。
|
||||||
|
|
||||||
|
## 源码调试
|
||||||
|
|
||||||
|
不编译直接跑插件源码,输出调用轨迹:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev debug [dir] # dir 默认当前目录
|
||||||
|
```
|
||||||
|
|
||||||
|
写 Lua 插件时更简单——`sdk.lua` 是 SDK 模拟层,可以直接用解释器跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [第一个 Go 插件](first-plugin.md)
|
||||||
|
- [第一个 Lua 插件](first-lua-plugin.md)
|
||||||
56
docs/guide/multi-platform.md
Normal file
56
docs/guide/multi-platform.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 多平台构建
|
||||||
|
|
||||||
|
## 默认就是多平台
|
||||||
|
|
||||||
|
`hmapdev build` 默认 bundle 模式,一次产出含三个平台的单个 `.hmap`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/myplugin_bundle.hmap
|
||||||
|
└── plugin.bin.linux.amd64
|
||||||
|
└── plugin.bin.darwin.amd64
|
||||||
|
└── plugin.bin.windows.amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
安装时内核挑当前平台那份,重命名为 `plugin.bin`。
|
||||||
|
|
||||||
|
## 逐平台构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build --no-bundle # 按 plg.json 的 targets 构建
|
||||||
|
hmapdev build --target linux/arm64 # 追加一个目标
|
||||||
|
```
|
||||||
|
|
||||||
|
`plg.json` 里声明目标:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "myplugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"targets": "linux/amd64,windows/amd64"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
单平台输出文件名:`{name}_{os}_{arch}.hmap`。
|
||||||
|
|
||||||
|
## 交叉编译
|
||||||
|
|
||||||
|
子进程插件**不再需要 cgo**,所以交叉编译不需要目标平台的 C 工具链 ——
|
||||||
|
这是 v1.0.0 的收益之一。
|
||||||
|
|
||||||
|
!!! note "bundle 模式忽略 `targets`"
|
||||||
|
固定构建 linux/amd64、darwin/amd64、windows/amd64。如果你只需要其中一个,
|
||||||
|
用 `--no-bundle` 更快。
|
||||||
|
|
||||||
|
## 平台能力差异
|
||||||
|
|
||||||
|
历史上有过一处真实的平台断层,现已消除:
|
||||||
|
|
||||||
|
- **v1.0.0 之前**:Windows 上插件只看到 **3 个 stage 字段、且无法写回**。
|
||||||
|
- **v1.0.0 起**:Windows 与其他平台**共用同一套 RPC 实现**,16 字段全可见 + 写回。
|
||||||
|
|
||||||
|
因此**不必**为 Windows 写条件分支 —— 除非你的插件自己用了平台专有的外部命令。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [打包与发布](packaging.md)
|
||||||
|
- [环境与工具链](getting-started.md)
|
||||||
114
docs/guide/packaging.md
Normal file
114
docs/guide/packaging.md
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# 打包与发布
|
||||||
|
|
||||||
|
`hmapdev build` 一次完成编译与打包,产出 `.hmap` 分发包(zip 格式,内含
|
||||||
|
`plugin.json` 清单 + 二进制)。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # 默认 bundle(多平台合集)
|
||||||
|
hmapdev build --no-bundle # 只构建 plg.json targets 里的平台
|
||||||
|
hmapdev build --target linux/arm64 # 在 targets 基础上追加目标
|
||||||
|
hmapdev build --outdir out # 指定输出目录(默认 dist)
|
||||||
|
hmapdev build --sdk-path <path> # 覆盖 go.mod 的 replace 指向的 SDK
|
||||||
|
hmapdev build --replace <mod@path> # 追加 go.mod replace(可多次)
|
||||||
|
```
|
||||||
|
|
||||||
|
执行流程:
|
||||||
|
|
||||||
|
1. 读 `plg.json` 的 `targets` / `bundle` 决定构建目标
|
||||||
|
2. 生成子进程运行时代码(`z_proc_gen.go`、`z_proc_shm_*.go`)
|
||||||
|
3. **Go 插件**:`go build`(普通可执行文件,`CGO_ENABLED=0`)
|
||||||
|
**Lua 插件**:直接打包源码,不编译
|
||||||
|
4. 生成 `plugin.json` 输出清单
|
||||||
|
5. 打成 `.hmap`
|
||||||
|
|
||||||
|
## 两个 JSON 的区别
|
||||||
|
|
||||||
|
这一点经常混淆:
|
||||||
|
|
||||||
|
| 文件 | 谁维护 | 作用 | 关键字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `plg.json` | **你** | 项目元信息,构建输入 | `targets`、`bundle` |
|
||||||
|
| `plugin.json` | `hmapdev` 自动生成 | 构建产物清单 | `entry`、`platforms` |
|
||||||
|
|
||||||
|
`plg.json` 里的 `sdk` 字段声明**本插件针对的 SDK 版本**;未命中本地 SDK 存储
|
||||||
|
会明确报错(见[环境与工具链](getting-started.md))。
|
||||||
|
|
||||||
|
## 多平台(bundle)
|
||||||
|
|
||||||
|
`build` 默认就是 bundle 模式:一次编译 linux/amd64、darwin/amd64、windows/amd64,
|
||||||
|
产出一个含全部平台二进制的 `.hmap`;安装时内核挑当前平台那份。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # → dist/myplugin_bundle.hmap
|
||||||
|
hmapdev build --no-bundle # → dist/myplugin_linux_amd64.hmap 等
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note "bundle 模式会忽略 `plg.json` 的 `targets`"
|
||||||
|
固定构建上述三个平台。交叉编译需要对应工具链(如 Linux 上构建 darwin 需要
|
||||||
|
clang / macOS SDK),缺工具链时会失败 —— 此时用 `--no-bundle` 只构建当前平台。
|
||||||
|
|
||||||
|
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分,安装时重命名为 `plugin.bin`。
|
||||||
|
|
||||||
|
## 产物形态
|
||||||
|
|
||||||
|
子进程插件是**普通可执行文件**,不分平台后缀:
|
||||||
|
|
||||||
|
| 平台 | 二进制 |
|
||||||
|
|---|---|
|
||||||
|
| Linux / macOS / Windows | `plugin.bin` |
|
||||||
|
|
||||||
|
!!! warning "v1.0.0 破坏性变更:不再加载 `.so` / `.dll`"
|
||||||
|
外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
|
||||||
|
|
||||||
|
- `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。
|
||||||
|
新内核遇到旧产物会跳过并报可操作错误,不崩溃。
|
||||||
|
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev`
|
||||||
|
(原 `plugindev`)重编即可。
|
||||||
|
- `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写 `plugin.so` 也无妨),
|
||||||
|
现在只用于区分 Lua 插件。
|
||||||
|
- 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
三种方式(`9876` 是 pluginmgr 的本地端口,默认只监听 `127.0.0.1`、无鉴权):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 URL 安装(仅 http/https,流式下载不落盘)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 从本地路径安装(读取文件,不移动原文件)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 直接上传二进制
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
--data-binary @dist/myplugin_bundle.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后调用 `/api/v1/plugins/reload` 或重启内核生效。
|
||||||
|
|
||||||
|
走 WebUI 的 HTTP API(默认 `8080`,需 `api_key` 鉴权,内部代理到 pluginmgr):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/plugins \
|
||||||
|
-H "Authorization: Bearer <api_key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在 WebUI 的插件管理页面上传。
|
||||||
|
|
||||||
|
## 发布前自查
|
||||||
|
|
||||||
|
- [ ] `plg.json` 的 `sdk` 版本与目标内核匹配
|
||||||
|
- [ ] `version` 已递增(内核按版本判断是否需要重装)
|
||||||
|
- [ ] 若插件有外部状态,`SetAutoRestart(false)` 或在 `Start` 里重建连接
|
||||||
|
(崩溃重启是**线性退避** 1s→2s→3s,5 分钟内第 4 次崩溃即停止,
|
||||||
|
见[生命周期](../api/lifecycle.md#pluginsdksetautorestart))
|
||||||
|
- [ ] `RegisterOnRemoveHandler` 里清理自己写下的数据文件
|
||||||
|
- [ ] 在 `-race` 下跑一遍:插件的 `Start` 与工具的并发访问是最常见的竞态来源
|
||||||
81
docs/guide/security.md
Normal file
81
docs/guide/security.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# 受限 SDK 与安全
|
||||||
|
|
||||||
|
外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**:
|
||||||
|
外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。
|
||||||
|
|
||||||
|
## 三层隔离
|
||||||
|
|
||||||
|
| 层 | 机制 | 防住了什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** |
|
||||||
|
| **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 |
|
||||||
|
| **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 |
|
||||||
|
|
||||||
|
第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益:
|
||||||
|
在此之前,插件 panic 会带崩 `homed`。
|
||||||
|
|
||||||
|
## 受限接口是怎么实现的
|
||||||
|
|
||||||
|
**按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的
|
||||||
|
接口声明**,公开的那个只保留安全子集:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 公开 SDK:6 个只读方法
|
||||||
|
type SocialAPI interface {
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**,
|
||||||
|
不是靠运行时检查拦截。
|
||||||
|
|
||||||
|
同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 插件可以订阅,但由内核决定投递哪些事件
|
||||||
|
type EventSubscriber interface {
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进程边界带来的约束
|
||||||
|
|
||||||
|
事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`,
|
||||||
|
但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber`
|
||||||
|
在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe`
|
||||||
|
RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。
|
||||||
|
|
||||||
|
这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。
|
||||||
|
详见[能力边界](capability-boundary.md)。
|
||||||
|
|
||||||
|
## 共享内存中的数据面
|
||||||
|
|
||||||
|
工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**,
|
||||||
|
RPC 只传偏移描述符。因此:
|
||||||
|
|
||||||
|
- 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大;
|
||||||
|
- StageContext 在同一份状态上读改写,消除了副本模型的 lost update
|
||||||
|
(实测由 35.8~36.8% 降到 0)。
|
||||||
|
|
||||||
|
`SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 ——
|
||||||
|
公开 SDK 只暴露普通字符串与 map。
|
||||||
|
|
||||||
|
## 插件作者的实践建议
|
||||||
|
|
||||||
|
- **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。
|
||||||
|
- **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护
|
||||||
|
(`example/memo` 用 `sync.RWMutex`)。
|
||||||
|
- **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。
|
||||||
|
- **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西,
|
||||||
|
别让它们污染记忆(`InjectOptions{NoMemory: true}`)。
|
||||||
|
- **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。
|
||||||
|
|
||||||
|
## 许可与分发
|
||||||
|
|
||||||
|
SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。
|
||||||
|
这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会
|
||||||
|
强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。
|
||||||
49
docs/index.md
Normal file
49
docs/index.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
用 **Go** 或 **Lua** 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
|
||||||
|
<div id="api-search"></div>
|
||||||
|
|
||||||
|
## 从这里开始
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-rocket-launch: **第一次写插件**
|
||||||
|
|
||||||
|
装工具链、生成工程、写一个工具、打包成 `.hmap` 装进内核跑起来。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 快速开始](guide/getting-started.md)
|
||||||
|
|
||||||
|
- :material-book-open-variant: **API 参考**
|
||||||
|
|
||||||
|
逐个符号的签名与说明,直接取自源码注释。附示例插件里的真实调用点。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 工具(Tools)](api/tools.md)
|
||||||
|
|
||||||
|
- :material-shield-lock: **能力边界**
|
||||||
|
|
||||||
|
哪些 API 外部插件能用、哪些仅内置插件可用,以及为什么。**先看这个能省很多时间。**
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 能力边界](guide/capability-boundary.md)
|
||||||
|
|
||||||
|
- :material-code-braces: **示例插件**
|
||||||
|
|
||||||
|
`example/` 下有多个真实可编译的插件,覆盖常见形态。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 示例总览](examples/index.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
SDK 以 **MIT** 发布 —— 插件作者可**自由选择自己的许可**(闭源、商业、私有均可),
|
||||||
|
不必同许可、也不必回馈。原因:SDK 会随插件一起静态链接(源码进入插件二进制),
|
||||||
|
若用传染性许可,插件作者就被强制开源;MIT 让第三方插件生态不必承担这个代价。
|
||||||
|
|
||||||
|
内核本身是 **AGPL-3.0-only**,但那是内核的许可,与外部插件无关 ——
|
||||||
|
SDK 完全自包含(`go.mod` 零外部依赖,只依赖 Go 标准库),不引用内核任何代码。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
本文档站的 API 参考从源码生成,对应 SDK 版本见 [版本与兼容](versions.md)。
|
||||||
264
docs/javascripts/api-search.js
Normal file
264
docs/javascripts/api-search.js
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* API 即时检索。
|
||||||
|
*
|
||||||
|
* 为什么要自建:Material 内置搜索按「整页文本」建索引,搜 `InjectText`
|
||||||
|
* 会把所有提到它的页面都列出来,但**分不清哪一条是它的定义**;而且内置
|
||||||
|
* 索引要等 mkdocs build 才生成,改一行 API 也得重建。
|
||||||
|
*
|
||||||
|
* 这里读的是 `assets/api-index.json`——由 tools/apidoc/gensite 直接产出,
|
||||||
|
* 每条记录带 名称/签名/描述/类别/所属页面/是否仅内置/源文件:行号。
|
||||||
|
* 因此可以做到:
|
||||||
|
* - 按名称搜(精确/前缀优先)
|
||||||
|
* - 按描述搜(中文按字、英文按词,都对 API 的文档注释做匹配)
|
||||||
|
* - 按签名搜(如 "(string) error")
|
||||||
|
* - 过滤「仅内置」——外部插件作者最容易被这个绊住
|
||||||
|
*
|
||||||
|
* 设计取舍:纯前端、零依赖、不阻塞页面。索引 ~130 条、约 40KB,一次拉取足够。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var INDEX_URL = (function () {
|
||||||
|
// 文档站可能部署在子路径下,按当前页面深度回推到站点根。
|
||||||
|
var path = window.location.pathname;
|
||||||
|
var marker = "/api/";
|
||||||
|
var i = path.indexOf(marker);
|
||||||
|
if (i >= 0) return path.slice(0, i) + "/assets/api-index.json";
|
||||||
|
// guide/ 等目录同样回退一层。
|
||||||
|
var lastSlash = path.lastIndexOf("/");
|
||||||
|
return path.slice(0, lastSlash) + "/assets/api-index.json";
|
||||||
|
})();
|
||||||
|
|
||||||
|
var state = { all: [], loaded: false, loading: false };
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (state.loaded || state.loading) return Promise.resolve(state.all);
|
||||||
|
state.loading = true;
|
||||||
|
return fetch(INDEX_URL)
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
state.all = data || [];
|
||||||
|
state.loaded = true;
|
||||||
|
return state.all;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
state.all = [];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 打分 ---------- */
|
||||||
|
//
|
||||||
|
// 三级优先级:名称命中 > 描述命中 > 签名命中。
|
||||||
|
// 名称命中里再分「完全相等 / 前缀 / 子串」,因为用户敲 `InjectText` 时
|
||||||
|
// 想要的是那个符号,不是所有名字里含它的。
|
||||||
|
|
||||||
|
function score(item, q) {
|
||||||
|
var name = (item.n || "").toLowerCase();
|
||||||
|
var ql = q.toLowerCase();
|
||||||
|
var s = 0;
|
||||||
|
|
||||||
|
if (name === ql) s += 1000;
|
||||||
|
else if (name.indexOf(ql) === 0) s += 600;
|
||||||
|
else if (name.indexOf(ql) > 0) s += 350;
|
||||||
|
|
||||||
|
// 中文检索关键词(keywords.json 产出,字段 g)。
|
||||||
|
// 为什么需要:SDK 里 66/100 个符号是英文注释(`RegisterTool registers a
|
||||||
|
// tool that the LLM can call.`),懂中文的人搜「注册工具」会一条都找不到。
|
||||||
|
// 关键词命中给较高权重(仅次于名称精确命中),因为它就是为「按功能找」准备的。
|
||||||
|
var kws = item.g || [];
|
||||||
|
for (var ki = 0; ki < kws.length; ki++) {
|
||||||
|
var kw = String(kws[ki]).toLowerCase();
|
||||||
|
if (kw === ql) { s += 480; break; }
|
||||||
|
if (kw.indexOf(ql) >= 0) { s += 300; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限定符:PluginSDK.RegisterTool / IOInjector.InjectText。
|
||||||
|
// 额外支持「去掉 API/SDK 后缀」与「去掉点号」两种写法,
|
||||||
|
// 因为读者习惯写 `memory.recall`(RPC 名),而 Go 名是 `MemoryAPI.Recall`。
|
||||||
|
var qual = ((item.r || "") + "." + name).toLowerCase();
|
||||||
|
if (item.r && qual.indexOf(ql) >= 0) s += 200;
|
||||||
|
if (item.r) {
|
||||||
|
var flat = qual.replace(/[._]/g, "").replace(/apis?dk|sdk|api/g, "");
|
||||||
|
var qflat = ql.replace(/[._\s]/g, "");
|
||||||
|
if (qflat && flat.indexOf(qflat) >= 0) s += 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desc = (item.d || "").toLowerCase();
|
||||||
|
if (desc.indexOf(ql) >= 0) s += 120;
|
||||||
|
|
||||||
|
// 签名按 token 匹配:把查询拆词(去掉括号/逗号等标点),全部命中才算。
|
||||||
|
// 这样 `(string) error`、`ContentBlock 媒体` 这类片段都能搜到。
|
||||||
|
// 注意必须先去标点:否则 token `(string)` 永远匹配不到签名里的 `string`。
|
||||||
|
var sig = (item.s || "").toLowerCase();
|
||||||
|
if (sig.indexOf(ql) >= 0) s += 60;
|
||||||
|
var toks = ql
|
||||||
|
.replace(/[()\[\]{},;:]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(function (t) { return t.length > 1; });
|
||||||
|
if (toks.length && sig.length) {
|
||||||
|
var allSig = toks.every(function (t) { return sig.indexOf(t) >= 0; });
|
||||||
|
if (allSig) s += 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文按字匹配:中文没有词边界,逐字命中比整串更实用。
|
||||||
|
// 注意只把它当作**弱信号**:光靠逐字会把「注册工具」匹到凡是含「工具」
|
||||||
|
// 字样的任何东西(实测 ContextPolicyNone 的说明里有「工具调用」也会命中)。
|
||||||
|
// 所以阈值卡在 60% 以上才算有效命中。
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(q)) {
|
||||||
|
var hit = 0;
|
||||||
|
var hay = desc + " " + kws.join(" ");
|
||||||
|
for (var i = 0; i < q.length; i++) {
|
||||||
|
if (hay.indexOf(q[i]) >= 0) hit++;
|
||||||
|
}
|
||||||
|
var ratio = hit / q.length;
|
||||||
|
if (ratio >= 0.6) s += Math.round(hit * 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开 API 略优先于「仅内置」——后者通常是噪声。
|
||||||
|
if (s > 0 && !item.b) s += 15;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(q) {
|
||||||
|
var qq = (q || "").trim();
|
||||||
|
if (!qq) return [];
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < state.all.length; i++) {
|
||||||
|
var sc = score(state.all[i], qq);
|
||||||
|
if (sc > 0) out.push({ item: state.all[i], score: sc });
|
||||||
|
}
|
||||||
|
out.sort(function (a, b) {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return (a.item.n || "").length - (b.item.n || "").length;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 渲染 ---------- */
|
||||||
|
//
|
||||||
|
// 挂在 Material 首页/目录页的一个容器上:#api-search。
|
||||||
|
// 没找到容器就不做任何事——这样同一份 JS 可以安全地全站引入。
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(mount, q) {
|
||||||
|
mount.innerHTML = "";
|
||||||
|
if (!q.trim()) {
|
||||||
|
mount.appendChild(el("p", "api-hint",
|
||||||
|
"输入 API 名称、描述或签名片段。例:InjectText、注册工具、崩溃、memory.recall、ContentBlock"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var results = search(q);
|
||||||
|
if (!results.length) {
|
||||||
|
mount.appendChild(el("p", "api-hint", "没有匹配的 API。试试更短的词,或按功能描述搜(如「注入」「重载」)。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var head = el("p", "api-count", "命中 " + results.length + " 个 API");
|
||||||
|
mount.appendChild(head);
|
||||||
|
|
||||||
|
var list = el("ul", "api-results");
|
||||||
|
results.slice(0, 40).forEach(function (r) {
|
||||||
|
var it = r.item;
|
||||||
|
var li = el("li", "api-result");
|
||||||
|
|
||||||
|
var title = el("a", "api-name", (it.r ? it.r + "." : "") + it.n);
|
||||||
|
// 锚点必须用**完整标题文本**(`PluginSDK.InjectText`,点号被 slug 丢掉),
|
||||||
|
// 不是裸方法名 —— 否则跳到页面顶部而到不了那一条。
|
||||||
|
title.href = pageURL(it.p) + "#" + anchorOf((it.r ? it.r + "." : "") + it.n);
|
||||||
|
li.appendChild(title);
|
||||||
|
|
||||||
|
if (it.b) {
|
||||||
|
var badge = el("span", "api-badge api-badge-builtin", "仅内置");
|
||||||
|
badge.title = "外部(第三方)插件运行时拿不到这个 API";
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(el("code", "api-sig", it.s || ""));
|
||||||
|
|
||||||
|
if (it.d) {
|
||||||
|
var d = el("span", "api-desc", it.d);
|
||||||
|
li.appendChild(d);
|
||||||
|
}
|
||||||
|
// 关键词是给检索用的;显示出来能让读者明白“为什么这条被匹配到”。
|
||||||
|
if (it.g && it.g.length) {
|
||||||
|
li.appendChild(el("span", "api-kw", it.g.slice(0, 6).join(" · ")));
|
||||||
|
}
|
||||||
|
if (it.f) {
|
||||||
|
li.appendChild(el("span", "api-loc", it.f + (it.l ? ":" + it.l : "")));
|
||||||
|
}
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
mount.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageURL(page) {
|
||||||
|
if (!page) return "#";
|
||||||
|
// 所有 API 章节都在 /api/ 下(生成物),示例页在 /examples/。
|
||||||
|
// 从当前 URL 回推到站点根,保证部署在子路径下也能用。
|
||||||
|
var path = window.location.pathname;
|
||||||
|
var i = path.indexOf("/api/");
|
||||||
|
var root;
|
||||||
|
if (i >= 0) {
|
||||||
|
root = path.slice(0, i + 1);
|
||||||
|
} else {
|
||||||
|
var j = path.indexOf("/guide/");
|
||||||
|
if (j >= 0) root = path.slice(0, j + 1);
|
||||||
|
else if (path.indexOf("/examples/") >= 0) root = path.slice(0, path.indexOf("/examples/") + 1);
|
||||||
|
else root = path.slice(0, path.lastIndexOf("/") + 1);
|
||||||
|
}
|
||||||
|
var dir = page === "examples" ? "examples" : "api";
|
||||||
|
return root + dir + "/" + page + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// anchorOf 复现 MkDocs 的 slug:小写、去掉非 [a-z0-9_-] 的字符(点号被去掉)、
|
||||||
|
// 下划线保留、空格转连字符。
|
||||||
|
function anchorOf(name) {
|
||||||
|
return String(name)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_ -]/g, "")
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
var box = document.getElementById("api-search");
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
var input = el("input", "api-input");
|
||||||
|
input.type = "search";
|
||||||
|
input.placeholder = "搜索 API:名称、描述、签名…";
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
input.setAttribute("spellcheck", "false");
|
||||||
|
|
||||||
|
var out = el("div", "api-output");
|
||||||
|
box.appendChild(input);
|
||||||
|
box.appendChild(out);
|
||||||
|
|
||||||
|
load().then(function () {
|
||||||
|
render(out, "");
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
render(out, input.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 支持 ?q= 直达(可从别处链接到一次检索)。
|
||||||
|
var m = /[?&]q=([^&]+)/.exec(window.location.search);
|
||||||
|
if (m) {
|
||||||
|
input.value = decodeURIComponent(m[1].replace(/\+/g, " "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", mount);
|
||||||
|
} else {
|
||||||
|
mount();
|
||||||
|
}
|
||||||
|
})();
|
||||||
33
docs/llms.txt
Normal file
33
docs/llms.txt
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
>
|
||||||
|
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
|
||||||
|
>
|
||||||
|
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
|
||||||
|
> 不含 HTML 样板;也可以直接取 https://sdk.homeagent.jianfgit.xyz/llms-full.txt 一次读完全部文档。
|
||||||
|
|
||||||
|
- [HomeAgent 插件 SDK](https://sdk.homeagent.jianfgit.xyz/index.md): 用 Go 或 Lua 为 HomeAgent 编写插件
|
||||||
|
- [桥接装配点(Bridge)](https://sdk.homeagent.jianfgit.xyz/api/bridge.md): 以下方法不是给插件业务代码调的——它们由 hmapdev 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例
|
||||||
|
- [仅内置插件可用的 API](https://sdk.homeagent.jianfgit.xyz/api/builtin-only.md): 这些 API 存在于公开 SDK 包里,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级
|
||||||
|
- [输入 / 输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels.md): 通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口
|
||||||
|
- [常量与枚举](https://sdk.homeagent.jianfgit.xyz/api/constants.md): SDK 里的取值枚举
|
||||||
|
- [事件(Events)](https://sdk.homeagent.jianfgit.xyz/api/events.md): 订阅内核事件
|
||||||
|
- [API 参考](https://sdk.homeagent.jianfgit.xyz/api/index.md): 本页所有内容从源码生成(tools/apidoc),签名与说明直接取自 sdk/*
|
||||||
|
- [生命周期(Lifecycle)](https://sdk.homeagent.jianfgit.xyz/api/lifecycle.md): 插件的启动、停止与卸载回调
|
||||||
|
- [LLM 调用](https://sdk.homeagent.jianfgit.xyz/api/llm.md): 让插件自己调用模型(而不是只等模型来调你)
|
||||||
|
- [记忆(Memory)](https://sdk.homeagent.jianfgit.xyz/api/memory.md): 三层记忆的读写接口:图记忆(三元组关系)、文档记忆(带元数据的文档)、文本记忆(事件流水)
|
||||||
|
- [其他类型](https://sdk.homeagent.jianfgit.xyz/api/misc.md): 剩余的类型与方法:PluginSDK 本体的访问器、StageContext 的并发控制,以及多模态辅助类型
|
||||||
|
- [配置(Settings)](https://sdk.homeagent.jianfgit.xyz/api/settings.md): 声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表
|
||||||
|
- [阶段钩子(Stages)](https://sdk.homeagent.jianfgit.xyz/api/stages.md): 在消息处理管道的固定点位插入自己的逻辑
|
||||||
|
- [工具(Tools)](https://sdk.homeagent.jianfgit.xyz/api/tools.md): 注册 LLM 可调用的工具
|
||||||
|
- [示例插件](https://sdk.homeagent.jianfgit.xyz/examples/index.md): SDK 仓 example/ 下有多个真实可编译的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态
|
||||||
|
- [能力边界:哪些 API 外部插件能用](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary.md): HomeAgent 有两类插件:
|
||||||
|
- [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin.md): Lua 插件适合轻量、快速原型:不需要 Go 编译环境,改完重启内核即可生效
|
||||||
|
- [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin.md): 以下是一个能直接跑起来的最小插件:注册一个工具、声明一项配置、处理停止与卸载
|
||||||
|
- [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
|
||||||
|
- [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个
|
||||||
|
- [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出
|
||||||
|
- [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界:
|
||||||
|
- [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为
|
||||||
123
docs/stylesheets/extra.css
Normal file
123
docs/stylesheets/extra.css
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
/* API 即时检索与文档站的少量本地样式。
|
||||||
|
只补 Material 没覆盖的部分,不覆盖主题变量(保持深浅色自动适配)。 */
|
||||||
|
|
||||||
|
#api-search {
|
||||||
|
margin: 1.2rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--md-default-fg-color--lightest);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--md-default-bg-color);
|
||||||
|
color: var(--md-default-fg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-input:focus {
|
||||||
|
outline: 2px solid var(--md-accent-fg-color);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-hint {
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-count {
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 0.8rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-result {
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border-left: 3px solid var(--md-primary-fg-color);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: var(--md-code-bg-color);
|
||||||
|
border-radius: 0 0.2rem 0.2rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-name {
|
||||||
|
font-family: var(--md-code-font-family, monospace);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sig {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-desc {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「为何命中」的中文关键词(keywords.json 产出)。 */
|
||||||
|
.api-kw {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--md-accent-fg-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-loc {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--md-default-fg-color--lighter, var(--md-default-fg-color--light));
|
||||||
|
font-family: var(--md-code-font-family, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 0.08rem 0.34rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-badge-builtin {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #b45309;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-color-scheme="slate"] .api-badge-builtin {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「仅内置」告警块里的依据说明通常很长,窄屏下允许更小字号。 */
|
||||||
|
@media screen and (max-width: 44.98em) {
|
||||||
|
.api-sig { font-size: 0.68rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 签名与长类型定义可能超出正文宽度(如 ContentBlock 的字段列表)。
|
||||||
|
highlight 代码块默认不换行,靠横向滚动;把默认改为换行显示,
|
||||||
|
因为文档读者更希望一眼看全签名而不是拖滚动条。 */
|
||||||
|
.md-typeset pre > code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 接口方法表里的签名可能很长,允许在任意位置折行。 */
|
||||||
|
.md-typeset table code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
77
docs/versions.md
Normal file
77
docs/versions.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# 版本与兼容
|
||||||
|
|
||||||
|
## SDK 版本语义
|
||||||
|
|
||||||
|
**SDK 版本跟随内核的中版本,patch 位恒为 `.0`。**
|
||||||
|
|
||||||
|
整条内核 `1.1.x` 线(1.1.0、1.1.1、1.1.7…)共用 **SDK 1.1.0**;
|
||||||
|
只有内核进入 `1.2.0` 这种中版本跃迁时,SDK 才升到 1.2.0。
|
||||||
|
|
||||||
|
这样插件作者只需关心「我在为哪个中版本写插件」,不必跟着内核的每个 bugfix 换依赖。
|
||||||
|
当前内核声明的兼容上限是 **SDK 1.3.0**。
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| SDK | 内核 | 变化 | 需要重编? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1.3.0** | 1.4.0+ | 驻留子 agent、`RecallPolicy` 等 | 想用新 API 才需要 |
|
||||||
|
| **1.2.0** | 1.2.0 / 1.3.x | `InjectOptions{NoMemory, ContextPolicy}`、六个 `*Opts` 变体、`ChannelDef.ContextPolicy` | 不需要 |
|
||||||
|
| **1.1.0** | 1.1.x | 多模态贯通:`Triple.SentenceText`、`Doc.Attachments`、`MediaAttachment`、`InsertWithMedia`、媒体注入方法 | 不需要 |
|
||||||
|
| **1.0.0** | 1.0.0+ | **运行模型变更**:C ABI 动态库 → 子进程 + 共享内存 | **需要** |
|
||||||
|
|
||||||
|
### 1.0.0 是唯一一次破坏性变更
|
||||||
|
|
||||||
|
- `.so` / `.dylib` / `.dll` **不再被加载**,遇到旧产物会跳过并报可操作错误(不崩溃)。
|
||||||
|
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` 重编即可。
|
||||||
|
- 产物从 `plugin.so` 变为 `plugin.bin`;不再需要 cgo。
|
||||||
|
|
||||||
|
### 1.1.0 / 1.2.0 是纯追加
|
||||||
|
|
||||||
|
两次都是**新增方法由插件调用、内核实现**,不调就不受影响。
|
||||||
|
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
|
||||||
|
想用新字段的重编即可。
|
||||||
|
|
||||||
|
!!! tip "什么时候必须重编"
|
||||||
|
只有两种情况:① 内核跨了中版本(如 1.1 → 1.2)且你用了新 API;
|
||||||
|
② 内核的 RPC 协议版本变了(`.hmap` 里的 `protocol` 字段与内核不匹配)。
|
||||||
|
后者的错配**不会静默失效** —— 握手时会显式拦下。
|
||||||
|
|
||||||
|
## RPC 协议版本
|
||||||
|
|
||||||
|
插件包里带 `protocol` 字段,必须等于内核的 `ProtocolVersion`(当前 **2**)。
|
||||||
|
|
||||||
|
协议 v2 引入了调用帧(tool / cleaner / output)与 `blocks_ref` 媒体块。
|
||||||
|
v1 插件遇上 v2 内核会拿到空参数,反过来 v2 插件发 `blocks_ref` 会被 v1 内核静默忽略 ——
|
||||||
|
**两边错配都不报错、只是静默失效**,所以协议版本在握手上显式校验。
|
||||||
|
|
||||||
|
## 怎么确认自己在用什么
|
||||||
|
|
||||||
|
装的 SDK 版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk current
|
||||||
|
hmapdev sdk list
|
||||||
|
```
|
||||||
|
|
||||||
|
插件声明的目标版本在 `plg.json` 的 `sdk` 字段。若该版本不在本地存储里,
|
||||||
|
`hmapdev` 会**明确报错**,不静默降级 —— 静默降级会产出与内核协议不匹配的包,
|
||||||
|
那种失败要到运行时才暴露。
|
||||||
|
|
||||||
|
## 文档站对应的版本
|
||||||
|
|
||||||
|
本页与 [API 参考](api/index.md) 由 `tools/apidoc` 从源码生成,
|
||||||
|
内容随源码一起演进。发现文档与代码不一致时,**改的是源码注释**,
|
||||||
|
`go run ./tools/apidoc` 重新生成即可(见下方「维护」)。
|
||||||
|
|
||||||
|
## 维护(给 SDK 维护者)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd homeagent-sdk
|
||||||
|
go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
|
||||||
|
go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs -examples ./example
|
||||||
|
mkdocs serve # 本地预览
|
||||||
|
mkdocs build # 产出 site_build/
|
||||||
|
```
|
||||||
|
|
||||||
|
API 面的**能力分层**(哪些 API 仅内置可用)记在 `tools/apidoc/tiers.json`,
|
||||||
|
每条裁定都附源码依据 —— 改这里而不是改生成物。
|
||||||
47
example/a2a/README.md
Normal file
47
example/a2a/README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# a2a · Agent-to-Agent 通信
|
||||||
|
|
||||||
|
让本 Agent 与其他 Agent **双向互调**:既能对外暴露自己的能力,也能去问别的 Agent。
|
||||||
|
|
||||||
|
## 两个方向
|
||||||
|
|
||||||
|
| 方向 | 怎么实现 |
|
||||||
|
|---|---|
|
||||||
|
| **入站**(别人问我) | 插件起一个 HTTP 服务端,暴露 `/agent-card`(能力描述)与 `/a2a`(JSON-RPC 入口) |
|
||||||
|
| **出站**(我问别人) | 提供 `a2a_query` / `a2a_discover` 工具,主动向远端 A2A Agent 发起请求 |
|
||||||
|
|
||||||
|
## HTTP 端点
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /agent-card` | 返回 Agent Card:本 Agent 的能力描述,供对方发现 |
|
||||||
|
| `POST /a2a` | JSON-RPC 2.0 入口,接收对方的任务请求 |
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `a2a_a2a_query` | 向另一个 A2A Agent 发查询并取回复 |
|
||||||
|
| `a2a_a2a_discover` | 取对方的 Agent Card(能力描述) |
|
||||||
|
| `a2a_a2a_status` | 看本插件运行状态(监听地址、当前配置) |
|
||||||
|
| `a2a_a2a_configure` | 改配置并自动重启服务(可动态改监听地址) |
|
||||||
|
| `a2a_a2a_restart` | 重启 HTTP 服务端(连接异常或改配置后用) |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),按默认 `a2a_` 列出。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | `127.0.0.1:12000` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站、不入站) |
|
||||||
|
|
||||||
|
## 典型用法
|
||||||
|
|
||||||
|
1. **先发现再调用**:`a2a_discover` 拿对方能力 → 决定要不要发、发什么 → `a2a_query`。
|
||||||
|
跳过 discovery 直接问,容易问出对方不支持的东西。
|
||||||
|
2. **只出站**:把 `listen` 设为空,本 Agent 不外露端口,但仍能主动联系别人。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "a2a",
|
"name": "a2a",
|
||||||
"name_zh": "A2A 代理通信",
|
"name_zh": "A2A 代理通信",
|
||||||
"name_en": "A2A Agent Communication",
|
"name_en": "A2A Agent Communication",
|
||||||
"version": "1.0.0",
|
"version": "1.3.1",
|
||||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["a2a", "agent", "interop"],
|
"tags": [
|
||||||
|
"a2a",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
@ -17,22 +18,58 @@ import (
|
|||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
|
srvMu sync.Mutex
|
||||||
server *http.Server
|
server *http.Server
|
||||||
serverAddr string
|
serverAddr string
|
||||||
|
|
||||||
|
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
|
||||||
|
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
|
||||||
|
sessMu sync.Mutex
|
||||||
|
sessions map[string]*a2aSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
|
||||||
|
type a2aSession struct {
|
||||||
|
ID string
|
||||||
|
History []string // 轮次文本 [user1, agent1, user2, agent2, ...]
|
||||||
|
LastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。
|
||||||
|
const maxSessionTurns = 10
|
||||||
|
|
||||||
|
// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。
|
||||||
|
const sessionGCPeriod = 30 * time.Minute
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
|
p.sessions = make(map[string]*a2aSession)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
||||||
|
// 且 output_list_channels 可见(agent 能主动向 a2a 会话推送消息)。
|
||||||
|
if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
payload, _ := args["payload"].(string)
|
||||||
|
log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120))
|
||||||
|
return map[string]interface{}{"status": "ok"}, nil
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[%s] register output channel: %v", p.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话 GC:后台周期回收长期不用的会话
|
||||||
|
go p.sessionGCLoop()
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "listen", Default: "127.0.0.1:12000",
|
Key: "listen", Default: "127.0.0.1:12000",
|
||||||
Type: "string", DisplayName: "监听地址",
|
Type: "string", DisplayName: "监听地址",
|
||||||
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||||
Category: p.name,
|
Category: p.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Outbound: query + discover
|
// Outbound: query + discover
|
||||||
@ -41,9 +78,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||||
|
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||||
},
|
},
|
||||||
"required": []string{"agent_url", "query"},
|
"required": []string{"agent_url", "query"},
|
||||||
},
|
},
|
||||||
@ -97,7 +135,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
// Inbound HTTP server
|
// Inbound HTTP server
|
||||||
if addr, _ := s.Settings().Get("listen"); addr != nil {
|
if addr, _ := s.Settings().Get("listen"); addr != nil {
|
||||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
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 +150,69 @@ func (p *Plugin) Stop() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionGCLoop 周期清理超时会话。
|
||||||
|
func (p *Plugin) sessionGCLoop() {
|
||||||
|
ticker := time.NewTicker(sessionGCPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
p.sessMu.Lock()
|
||||||
|
for id, sess := range p.sessions {
|
||||||
|
if time.Since(sess.LastUsed) > 2*time.Hour {
|
||||||
|
delete(p.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionMessages 返回指定会话的近 limit 条消息(时间正序),
|
||||||
|
// 会话不存在返回 nil。消息格式 [{role, text, ts}]。
|
||||||
|
func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} {
|
||||||
|
p.sessMu.Lock()
|
||||||
|
sess := p.sessions[sessionID]
|
||||||
|
var hist []string
|
||||||
|
var lastUsed time.Time
|
||||||
|
if sess != nil {
|
||||||
|
hist = append([]string{}, sess.History...)
|
||||||
|
lastUsed = sess.LastUsed
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
if sess == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = lastUsed
|
||||||
|
// History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序
|
||||||
|
start := 0
|
||||||
|
if len(hist) > limit {
|
||||||
|
start = len(hist) - limit
|
||||||
|
}
|
||||||
|
msgs := make([]map[string]interface{}, 0, len(hist)-start)
|
||||||
|
for i := start; i < len(hist); i++ {
|
||||||
|
role, text := "user", hist[i]
|
||||||
|
if after, ok := strings.CutPrefix(text, "用户: "); ok {
|
||||||
|
role, text = "user", after
|
||||||
|
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
|
||||||
|
role, text = "agent", after
|
||||||
|
}
|
||||||
|
msgs = append(msgs, map[string]interface{}{
|
||||||
|
"role": role,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) stopServer() {
|
func (p *Plugin) stopServer() {
|
||||||
|
p.srvMu.Lock()
|
||||||
|
defer p.srvMu.Unlock()
|
||||||
if p.server != nil {
|
if p.server != nil {
|
||||||
p.server.Close()
|
p.server.Close()
|
||||||
p.server = nil
|
p.server = nil
|
||||||
@ -120,7 +222,7 @@ func (p *Plugin) stopServer() {
|
|||||||
|
|
||||||
// ---- Inbound HTTP Server ----
|
// ---- Inbound HTTP Server ----
|
||||||
|
|
||||||
func (p *Plugin) startServer(addr string) {
|
func (p *Plugin) startServer(addr string) error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||||
mux.HandleFunc("/task", p.handleIncomingTask)
|
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||||
@ -128,18 +230,32 @@ func (p *Plugin) startServer(addr string) {
|
|||||||
|
|
||||||
listener, err := net.Listen("tcp", addr)
|
listener, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[%s] listen %s: %v", p.name, addr, err)
|
return fmt.Errorf("listen %s: %v", addr, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p.server = &http.Server{Handler: mux}
|
srv := &http.Server{
|
||||||
p.serverAddr = listener.Addr().String()
|
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() {
|
go func() {
|
||||||
log.Printf("[%s] A2A server on %s", p.name, p.serverAddr)
|
log.Printf("[%s] A2A server on %s", p.name, addrStr)
|
||||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
log.Printf("[%s] serve: %v", p.name, err)
|
log.Printf("[%s] serve: %v", p.name, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -171,8 +287,10 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Params struct {
|
Params struct {
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
Message *struct {
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Message *struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Parts []struct {
|
Parts []struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
@ -195,29 +313,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
queryText = strings.TrimSpace(queryText)
|
queryText = strings.TrimSpace(queryText)
|
||||||
}
|
}
|
||||||
|
if queryText == "" {
|
||||||
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
http.Error(w, "query/message.text required", http.StatusBadRequest)
|
||||||
if queryText != "" {
|
return
|
||||||
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond with task accepted
|
// 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。
|
||||||
|
sessionID := strings.TrimSpace(req.Params.SessionID)
|
||||||
|
injectText := queryText
|
||||||
|
p.sessMu.Lock()
|
||||||
|
if sessionID != "" {
|
||||||
|
sess := p.sessions[sessionID]
|
||||||
|
if sess == nil {
|
||||||
|
sess = &a2aSession{ID: sessionID, LastUsed: time.Now()}
|
||||||
|
p.sessions[sessionID] = sess
|
||||||
|
}
|
||||||
|
sess.LastUsed = time.Now()
|
||||||
|
// 有历史则把上下文拼在前面(截尾防爆量)
|
||||||
|
if len(sess.History) > 0 {
|
||||||
|
ctxText := strings.Join(sess.History, "\n")
|
||||||
|
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano())
|
||||||
|
p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()}
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
|
||||||
|
// 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、
|
||||||
|
// 也不再回 202 让请求方永远等不到结果)。HTTP 超时由调用方控制。
|
||||||
|
reply := p.sdk.InjectInputSync(p.name, p.name,
|
||||||
|
fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name))
|
||||||
|
|
||||||
|
// 回复写回会话历史(下一轮作为上下文)
|
||||||
|
p.sessMu.Lock()
|
||||||
|
if sess := p.sessions[sessionID]; sess != nil {
|
||||||
|
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
||||||
|
if len(sess.History) > maxSessionTurns*2 {
|
||||||
|
sess.History = sess.History[len(sess.History)-maxSessionTurns*2:]
|
||||||
|
}
|
||||||
|
sess.LastUsed = time.Now()
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req.ID,
|
"id": req.ID,
|
||||||
"result": map[string]interface{}{
|
"result": map[string]interface{}{
|
||||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||||
"status": "submitted",
|
"status": "completed",
|
||||||
|
"session_id": sessionID,
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"role": "agent",
|
||||||
|
"parts": []map[string]string{{"type": "text", "text": reply}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
|
||||||
case "tasks.get":
|
case "tasks.get", "session.get":
|
||||||
|
// 按 session_id 返回会话内近 N 条消息(默认 10 条)。
|
||||||
|
sessionID := strings.TrimSpace(req.Params.SessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = strings.TrimSpace(req.Params.Query)
|
||||||
|
}
|
||||||
|
limit := 10
|
||||||
|
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
|
||||||
|
limit = req.Params.Limit
|
||||||
|
}
|
||||||
|
msgs := p.sessionMessages(sessionID, limit)
|
||||||
|
if msgs == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": "not_found",
|
||||||
|
"messages": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": req.ID,
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": "completed",
|
||||||
|
"messages": msgs,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -261,9 +446,10 @@ type A2ARequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type A2AParams struct {
|
type A2AParams struct {
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
TaskID string `json:"id,omitempty"`
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type A2AResponse struct {
|
type A2AResponse struct {
|
||||||
@ -274,9 +460,10 @@ type A2AResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type A2AResult struct {
|
type A2AResult struct {
|
||||||
TaskID string `json:"id,omitempty"`
|
TaskID string `json:"id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,6 +528,7 @@ func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, er
|
|||||||
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||||
agentURL, _ := args["agent_url"].(string)
|
agentURL, _ := args["agent_url"].(string)
|
||||||
query, _ := args["query"].(string)
|
query, _ := args["query"].(string)
|
||||||
|
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||||
timeoutSec := 60
|
timeoutSec := 60
|
||||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
timeoutSec = int(v)
|
timeoutSec = int(v)
|
||||||
@ -362,7 +550,8 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
|||||||
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||||
Method: "tasks.send",
|
Method: "tasks.send",
|
||||||
Params: A2AParams{
|
Params: A2AParams{
|
||||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
SessionID: sessionID,
|
||||||
|
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,34 +590,39 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
|||||||
replyText = strings.TrimSpace(replyText)
|
replyText = strings.TrimSpace(replyText)
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||||
"response": replyText,
|
"response": replyText,
|
||||||
}, nil
|
}
|
||||||
|
if a2aResp.Result.SessionID != "" || sessionID != "" {
|
||||||
|
result["session_id"] = a2aResp.Result.SessionID
|
||||||
|
if result["session_id"] == "" {
|
||||||
|
result["session_id"] = sessionID
|
||||||
|
}
|
||||||
|
result["note"] = "延续会话:下次调用传此 session_id 可保持上下文"
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Management Handlers ----
|
// ---- Management Handlers ----
|
||||||
|
|
||||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||||
listen, _ := args["listen"].(string)
|
listen, _ := args["listen"].(string)
|
||||||
if listen == "" {
|
listen = strings.TrimSpace(listen)
|
||||||
return "参数 listen 不能为空。设为空字符串可禁用 HTTP 服务。", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.stopServer()
|
if listen == "" || listen == "off" || listen == "disabled" {
|
||||||
if listen != "" {
|
p.stopServer()
|
||||||
p.startServer(listen)
|
return "A2A HTTP 服务已禁用(listen 设为空)", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
status := "已启动"
|
if err := p.startServer(listen); err != nil {
|
||||||
if listen == "" {
|
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
|
||||||
status = "已禁用"
|
|
||||||
}
|
}
|
||||||
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) {
|
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
|
||||||
@ -436,23 +630,28 @@ func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error)
|
|||||||
|
|
||||||
addr, _ := p.sdk.Settings().Get("listen")
|
addr, _ := p.sdk.Settings().Get("listen")
|
||||||
addrStr, _ := addr.(string)
|
addrStr, _ := addr.(string)
|
||||||
if addrStr == "" {
|
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
|
||||||
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
|
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.startServer(addrStr)
|
if err := p.startServer(addrStr); err != nil {
|
||||||
if p.server == nil {
|
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
|
||||||
return fmt.Sprintf("A2A 服务启动失败,请检查监听地址: %s", addrStr), 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) {
|
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||||
addr, _ := p.sdk.Settings().Get("listen")
|
addr, _ := p.sdk.Settings().Get("listen")
|
||||||
addrStr, _ := addr.(string)
|
addrStr, _ := addr.(string)
|
||||||
|
|
||||||
|
p.srvMu.Lock()
|
||||||
serverRunning := p.server != nil
|
serverRunning := p.server != nil
|
||||||
listening := p.serverAddr
|
listening := p.serverAddr
|
||||||
|
p.srvMu.Unlock()
|
||||||
if !serverRunning {
|
if !serverRunning {
|
||||||
listening = "未运行"
|
listening = "未运行"
|
||||||
}
|
}
|
||||||
|
|||||||
51
example/acp/README.md
Normal file
51
example/acp/README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# acp · Agent Client Protocol 通信
|
||||||
|
|
||||||
|
[ACP](https://agentclientprotocol.com/) 桥接:本 Agent 既能**当服务端**接别人的任务,也能**当客户端**去调别的 ACP Agent。
|
||||||
|
|
||||||
|
## 两个方向
|
||||||
|
|
||||||
|
| 角色 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| **服务端** | 在本机起 HTTP 服务,处理 `session/new` / `session/update`,接受其他 Agent 的任务请求 |
|
||||||
|
| **客户端** | 通过 `acp_query` 向远程 ACP Agent 发 `session/new` 并读回复 |
|
||||||
|
|
||||||
|
## 协议端点
|
||||||
|
|
||||||
|
- `POST /api/session` —— JSON-RPC,支持 `session/new` 与 `session/update`
|
||||||
|
- 客户端侧同时兼容**两种服务端**:SSE 型(流式 `session/reply`)与同步 JSON 型
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `acp_acp_query` | 向远程 ACP Agent 发起会话并等待回复,返回最终回答文本 |
|
||||||
|
| `acp_acp_status` | 查看运行状态与**当前活跃会话数** |
|
||||||
|
| `acp_acp_configure` | 改监听配置并重启 HTTP 服务 |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),按默认 `acp_` 列出。
|
||||||
|
|
||||||
|
`acp_query` 可指向的远端举例(源码注释给的):
|
||||||
|
|
||||||
|
- opencode:`http://127.0.0.1:13000`
|
||||||
|
- pi bridge:`http://127.0.0.1:12011`
|
||||||
|
- 回环到自身:`http://127.0.0.1:12001`
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | `127.0.0.1:12001` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站) |
|
||||||
|
|
||||||
|
## 与 a2a 的区别
|
||||||
|
|
||||||
|
| | a2a | acp |
|
||||||
|
|---|---|---|
|
||||||
|
| 面向 | Agent ↔ Agent 对等通信 | 客户端 → Agent 会话(每次一个 session) |
|
||||||
|
| 会话 | 一问一答 | 有 session 生命周期,可续 |
|
||||||
|
| 发现 | `/agent-card` | 无(需已知地址) |
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
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.1",
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
645
example/acp/plugin.go
Normal file
645
example/acp/plugin.go
Normal file
@ -0,0 +1,645 @@
|
|||||||
|
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)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -1,13 +1,34 @@
|
|||||||
# ai_image
|
# ai_image · 文生图
|
||||||
|
|
||||||
ai_image plugin
|
按文字提示生成图片,下载到本地并返回**文件路径**。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ai_image_generate` | 按 prompt 生成图片 |
|
||||||
|
|
||||||
|
返回值是**本地文件路径**(永久,不过期)。要把图给用户看,再用导出的通道
|
||||||
|
以 `type=image`、`payload=<该路径>` 发送。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `api_key` | 空 | OpenAI / Stable Diffusion 的 API Key |
|
||||||
|
| `base_url` | 空 | 自定义 OpenAI 兼容网关(**不带 `/v1` 尾缀**,如 `http://127.0.0.1:8081`)。留空走官方 `https://api.openai.com` |
|
||||||
|
| `provider` | `openai` | 服务方:`openai` / `stability` |
|
||||||
|
| `model` | `dall-e-3` | 模型名(如 `dall-e-3`、`sd-xl`) |
|
||||||
|
| `size` | `1024x1024` | 默认尺寸,也可 `1024x1792` / `1792x1024` |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **返回本地路径而不是远端 URL**:远端图床链接会过期,写进记忆就成了悬空指针。
|
||||||
|
下载到本地后路径稳定,可交给媒体存储做内容寻址。
|
||||||
|
- 配了 `base_url` 就能指向自建/兼容网关,不必依赖官方接口。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "ai_image",
|
"name": "ai_image",
|
||||||
"name_zh": "AI绘图",
|
"name_zh": "AI绘图",
|
||||||
"name_en": "AI Image",
|
"name_en": "AI Image",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["ai", "image", "draw", "generate"],
|
"tags": [
|
||||||
|
"ai",
|
||||||
|
"image",
|
||||||
|
"draw",
|
||||||
|
"generate"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -21,6 +24,8 @@ type Plugin struct {
|
|||||||
provider string
|
provider string
|
||||||
model string
|
model string
|
||||||
size string
|
size string
|
||||||
|
baseURL string
|
||||||
|
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -111,10 +116,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
||||||
Category: "ai_image", Secret: true,
|
Category: "ai_image", Secret: true,
|
||||||
})
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "base_url", Default: "", Type: "string",
|
||||||
|
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
|
||||||
|
Category: "ai_image",
|
||||||
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "provider", Default: "openai", Type: "string",
|
Key: "provider", Default: "openai", Type: "string",
|
||||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||||
Category: "ai_image",
|
Category: "ai_image",
|
||||||
})
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "model", Default: "dall-e-3", Type: "string",
|
Key: "model", Default: "dall-e-3", Type: "string",
|
||||||
@ -131,10 +141,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.provider = getSetting(s.Settings(), "provider", "openai")
|
p.provider = getSetting(s.Settings(), "provider", "openai")
|
||||||
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||||
|
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||||||
|
|
||||||
|
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||||
|
if p.sdk != nil {
|
||||||
|
if dd := s.Settings().DataDir(); dd != "" {
|
||||||
|
p.dataDir = dd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.dataDir == "" {
|
||||||
|
// 旧版内核无 DataDir API 时退到 /tmp
|
||||||
|
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||||
|
}
|
||||||
|
os.MkdirAll(p.dataDir, 0755)
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||||
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.",
|
Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -209,6 +232,14 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||||||
|
// 上游地址:base_url 非空时走自定义网关(如本机 llmsproxy),约定不带 /v1 尾缀;
|
||||||
|
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
|
||||||
|
endpoint := "https://api.openai.com/v1/images/generations"
|
||||||
|
if p.baseURL != "" {
|
||||||
|
base := strings.TrimSuffix(p.baseURL, "/v1")
|
||||||
|
endpoint = base + "/v1/images/generations"
|
||||||
|
}
|
||||||
|
|
||||||
body := openAIReq{
|
body := openAIReq{
|
||||||
Model: model,
|
Model: model,
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
|||||||
ResponseFormat: "url",
|
ResponseFormat: "url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
|
||||||
b, _ := json.Marshal(body)
|
b, _ := json.Marshal(body)
|
||||||
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b))
|
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
@ -247,14 +279,74 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
|||||||
urls[i] = d.URL
|
urls[i] = d.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL):
|
||||||
|
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
|
||||||
|
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
|
||||||
|
localPaths := make([]string, len(urls))
|
||||||
|
var errs []string
|
||||||
|
for i, u := range urls {
|
||||||
|
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localPaths[i] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
|
||||||
|
for _, pth := range localPaths {
|
||||||
|
if pth != "" {
|
||||||
|
content += "\n" + pth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
content += "\n\n" + strings.Join(errs, "\n")
|
||||||
|
}
|
||||||
|
content += "\n\n已将图片保存到本地(不会过期)。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
|
"content": content,
|
||||||
"images": urls,
|
"images": localPaths,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
"local_paths": localPaths,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
|
||||||
|
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
|
||||||
|
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
|
||||||
|
dl := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
|
||||||
|
resp, err := dl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ext := ".png"
|
||||||
|
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||||||
|
ext = ".jpg"
|
||||||
|
} else if strings.Contains(ct, "webp") {
|
||||||
|
ext = ".webp"
|
||||||
|
}
|
||||||
|
path := filepath.Join(p.dataDir, baseName+ext)
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
type stabilityReq struct {
|
type stabilityReq struct {
|
||||||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")),
|
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地,如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
|
||||||
"images": urls,
|
"images": urls,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
|||||||
43
example/bili/README.md
Normal file
43
example/bili/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# bili · B站视频下载
|
||||||
|
|
||||||
|
用 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 把 B 站视频下载到本地。
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
需要系统里装有 `yt-dlp`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U yt-dlp # 或 apt install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `bili_video` | 下载 B 站视频;不指定 `format` 时先返回可用清晰度列表,指定后真正下载并返回文件路径 |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `url` | 视频地址 |
|
||||||
|
| `format` | 格式 ID。常用:`30112`/`30080`=1080P、`30064`=720P、`30032`=480P、`30016`=360P。不指定则自动选最优 |
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `output_dir` | `/tmp/bili_videos` | 下载目录 |
|
||||||
|
| `proxy` | 空 | yt-dlp 使用的 HTTP 代理(如 `http://127.0.0.1:7890`)。留空则不设代理 |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **`output_dir` 有安全校验**:它是配置项,但会拒绝被配成系统目录,避免 yt-dlp 往任意位置写文件。
|
||||||
|
- 两阶段用法:先不传 `format` 拿到清晰度清单(`format_id` + `format_note`),再带上选定的 ID 下载。这样模型不会盲选一个不存在的格式。
|
||||||
|
- B 站在部分网络环境下需要代理,见上面的 `proxy`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "bili",
|
"name": "bili",
|
||||||
"name_zh": "B站视频下载",
|
"name_zh": "B站视频下载",
|
||||||
"name_en": "Bilibili Video Downloader",
|
"name_en": "Bilibili Video Downloader",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["bili", "video", "download"],
|
"tags": [
|
||||||
|
"bili",
|
||||||
|
"video",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -8,13 +8,15 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
|
proxy string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
@ -30,6 +32,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
Description: "B站视频下载后的保存目录",
|
Description: "B站视频下载后的保存目录",
|
||||||
Category: p.name,
|
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{
|
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||||
Name: tp + "video",
|
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)
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
|
||||||
var out bytes.Buffer
|
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 := exec.Command("yt-dlp", ytdlpArgs...)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &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 {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
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
|
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{
|
dlArgs := []string{
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--socket-timeout", "30",
|
"--socket-timeout", "30",
|
||||||
"--retries", "3",
|
"--retries", "3",
|
||||||
"--fragment-retries", "3",
|
"--fragment-retries", "3",
|
||||||
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
|
||||||
"--no-overwrites",
|
"--no-overwrites",
|
||||||
}
|
}
|
||||||
if format != "" {
|
if format != "" {
|
||||||
@ -184,7 +210,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
}
|
}
|
||||||
dlArgs = append(dlArgs, url)
|
dlArgs = append(dlArgs, url)
|
||||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
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
|
var dlOut bytes.Buffer
|
||||||
cmd2.Stdout = &dlOut
|
cmd2.Stdout = &dlOut
|
||||||
cmd2.Stderr = &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()))
|
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, _ := os.ReadDir(outputDir)
|
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
|
||||||
var newest string
|
for _, f := range parts {
|
||||||
var newestTime int64
|
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 {
|
for _, e := range entries {
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
continue
|
continue
|
||||||
@ -203,30 +238,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
if fi == nil {
|
if fi == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
t := fi.ModTime().Unix()
|
if fi.Size() > mainSize {
|
||||||
if t > newestTime {
|
mainSize = fi.Size()
|
||||||
newestTime = t
|
mainFile = e.Name()
|
||||||
newest = e.Name()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if newest == "" {
|
if mainFile == "" {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": "下载完成,但未找到视频文件",
|
"content": "下载完成,但未找到视频文件",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
dlPath := filepath.Join(outputDir, newest)
|
dlPath := filepath.Join(taskDir, mainFile)
|
||||||
fi, _ := os.Stat(dlPath)
|
|
||||||
var fileSize int64
|
|
||||||
if fi != nil {
|
|
||||||
fileSize = fi.Size()
|
|
||||||
}
|
|
||||||
return map[string]interface{}{
|
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,
|
"file": dlPath,
|
||||||
"filename": newest,
|
"filename": mainFile,
|
||||||
}, nil
|
}, 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 {
|
func contains(slice []string, s string) bool {
|
||||||
for _, v := range slice {
|
for _, v := range slice {
|
||||||
if v == s {
|
if v == s {
|
||||||
|
|||||||
66
example/browser/README.md
Normal file
66
example/browser/README.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# browser · 统一浏览器
|
||||||
|
|
||||||
|
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
|
||||||
|
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
|
||||||
|
|
||||||
|
## 三种能力层
|
||||||
|
|
||||||
|
| 层 | 工具 | 何时用 |
|
||||||
|
|---|---|---|
|
||||||
|
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
|
||||||
|
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
|
||||||
|
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
|
||||||
|
| **interactive(CDP)** | `browser_start` + `navigate`/`click`/`type`/`scroll`/`html`/`screenshot` | 需要交互:登录、点按、翻页 |
|
||||||
|
|
||||||
|
`browser_fetch` 的 `mode`:
|
||||||
|
|
||||||
|
- `auto`(默认):先试 HTTP,**遇 403/429 才降级**用 Chromium 渲染
|
||||||
|
- `render`:强制 Chromium
|
||||||
|
- `quick`:纯 HTTP,不降级
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `browser_search` | 网页搜索 |
|
||||||
|
| `browser_fetch` | 抓取 URL 内容,三种 mode 见上 |
|
||||||
|
| `browser_render` | 无头 Chromium 渲染并提取文本(normal) |
|
||||||
|
| `browser_start` | 启动交互式浏览器会话(CDP) |
|
||||||
|
| `browser_navigate` | 导航到指定 URL |
|
||||||
|
| `browser_click` | 点击元素 |
|
||||||
|
| `browser_type` | 输入文本 |
|
||||||
|
| `browser_scroll` | 滚动页面 |
|
||||||
|
| `browser_html` | 取当前页 HTML |
|
||||||
|
| `browser_screenshot` | 截图 |
|
||||||
|
| `browser_install` | 安装 systemd 托管的共享浏览器后端 |
|
||||||
|
| `browser_close` | 关闭会话 |
|
||||||
|
|
||||||
|
## 共享浏览器后端
|
||||||
|
|
||||||
|
`browser_install` 安装 `homeagent-browser.service`(systemd 托管)。
|
||||||
|
装上之后**所有 agent 共享同一个 Chromium 实例与登录态**,各自占独立标签页互不干扰
|
||||||
|
(同 source 复用自己的标签页)。
|
||||||
|
|
||||||
|
前提:本机已有 chromium 二进制,没有会提示先装(`apt install chromium` 或等价)。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **搜索用 `cn.bing.com` 而不是 `www.bing.com`**:后者对程序化请求常回 302(同意/重定向页),
|
||||||
|
根本拿不到结果块。
|
||||||
|
- **标题取 `<h2>` 里的 `<a>`**:直接抓结果块里第一个 `<a>` 会拿到来源行而非标题。
|
||||||
|
- **摘要认 `b_lineclamp`**:旧版 Bing 用 `b_caption`,新版已迁走,两套都匹配。
|
||||||
|
- **有 SSRF 防护**:见源码 `SSRF` 段,抓取前校验目标地址,避免被诱导访问内网。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`testdata/bing_cn.html` 是搜索解析的固定样本,用它做离线断言,避免测试依赖真实网络。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -17,10 +17,10 @@ require (
|
|||||||
golang.org/x/sys v0.16.0
|
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,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.0.0",
|
"version": "2.4.1",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
"tags": [
|
||||||
|
"web",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"browser",
|
||||||
|
"cdp"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -33,22 +35,49 @@ type Plugin struct {
|
|||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
sessions map[string]*BrowserSession
|
sessions map[string]*BrowserSession
|
||||||
nextID int
|
nextID int
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
|
||||||
|
|
||||||
|
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
|
||||||
|
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||||
|
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||||
|
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
||||||
|
sharedAllocCtx context.Context
|
||||||
|
sharedAllocCancel context.CancelFunc
|
||||||
|
sharedMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserSession struct {
|
type BrowserSession struct {
|
||||||
id string
|
id string
|
||||||
allocCtx context.Context
|
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
ctx context.Context
|
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
closed bool
|
closed bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentURL string
|
currentURL string
|
||||||
|
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
||||||
|
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
||||||
|
sessionKey string // 共享模式下的复用键(agent 来源标识,同 key 复用同一标签页)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_,防路径穿越。
|
||||||
|
func sanitizeProfileName(name string) string {
|
||||||
|
var b []byte
|
||||||
|
for _, c := range []byte(name) {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||||
|
b = append(b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b) == 0 || string(b) == "." || string(b) == ".." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -131,6 +160,18 @@ func errResult(msg string) map[string]interface{} {
|
|||||||
return map[string]interface{}{"isError": true, "content": msg}
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
|
||||||
|
raw := strings.TrimSpace(readArg(args, "timeout", ""))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
|
||||||
|
}
|
||||||
|
timeout, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || timeout <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
|
||||||
|
}
|
||||||
|
return timeout, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
@ -163,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
|||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "timeout", Default: "30", Type: "int",
|
Key: "timeout", Default: "30", Type: "int",
|
||||||
@ -186,6 +230,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.proxy = readCfg(s.Settings(), "proxy", "")
|
p.proxy = readCfg(s.Settings(), "proxy", "")
|
||||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||||
|
|
||||||
|
// 持久化 profile 根目录:<data>/browser_profiles
|
||||||
|
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
||||||
|
if s2, ok := dd.(string); ok && s2 != "" {
|
||||||
|
p.profilesDir = filepath.Join(s2, "browser_profiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
cleaner := func(output string) string {
|
cleaner := func(output string) string {
|
||||||
@ -241,13 +292,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||||
Name: tp + "start",
|
Name: tp + "start",
|
||||||
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。",
|
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
|
||||||
|
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||||
},
|
},
|
||||||
|
"required": []string{"timeout"},
|
||||||
},
|
},
|
||||||
}, p.handleBrowserStart)
|
}, p.handleBrowserStart)
|
||||||
|
|
||||||
@ -273,7 +326,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
|
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
|
||||||
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
|
"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"},
|
"required": []string{"id"},
|
||||||
},
|
},
|
||||||
@ -336,6 +389,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
},
|
},
|
||||||
}, p.handleScroll)
|
}, p.handleScroll)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"install", sdk.ToolDef{
|
||||||
|
Name: tp + "install",
|
||||||
|
Description: "安装并启动共享浏览器后端(homeagent-browser.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{
|
s.RegisterTool(tp+"close", sdk.ToolDef{
|
||||||
Name: tp + "close",
|
Name: tp + "close",
|
||||||
Description: "关闭交互式浏览器会话,释放资源。",
|
Description: "关闭交互式浏览器会话,释放资源。",
|
||||||
@ -356,18 +418,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
close(p.stopCh)
|
p.stopOnce.Do(func() {
|
||||||
p.wg.Wait()
|
close(p.stopCh)
|
||||||
if p.client != nil {
|
p.wg.Wait()
|
||||||
p.client.CloseIdleConnections()
|
if p.client != nil {
|
||||||
}
|
p.client.CloseIdleConnections()
|
||||||
p.mu.Lock()
|
}
|
||||||
for _, s := range p.sessions {
|
p.mu.Lock()
|
||||||
s.Close()
|
for _, s := range p.sessions {
|
||||||
}
|
s.Close()
|
||||||
p.sessions = nil
|
}
|
||||||
p.mu.Unlock()
|
p.sessions = nil
|
||||||
log.Printf("[%s] stopped", p.name)
|
p.mu.Unlock()
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,7 +488,9 @@ type searchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
||||||
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
|
// 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。
|
||||||
|
// 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
|
||||||
|
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
|
||||||
req, _ := http.NewRequest("GET", u, nil)
|
req, _ := http.NewRequest("GET", u, nil)
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
@ -434,38 +500,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return parseBingResults(string(body), count), nil
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body))
|
||||||
|
}
|
||||||
|
results := parseBingResults(string(body), count)
|
||||||
|
if len(results) == 0 {
|
||||||
|
// 关键:把「解析不出来」与「真的没结果」区分开。
|
||||||
|
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
|
||||||
|
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBingResults(html string, count int) []searchResult {
|
var (
|
||||||
|
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
|
||||||
|
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||||
|
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||||
|
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
|
||||||
|
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
|
||||||
|
//
|
||||||
|
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>(deep links),
|
||||||
|
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
|
||||||
|
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
|
||||||
|
func splitBingBlocks(pageHTML string) []string {
|
||||||
|
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
|
||||||
|
if len(locs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blocks := make([]string, 0, len(locs))
|
||||||
|
for i, loc := range locs {
|
||||||
|
end := len(pageHTML)
|
||||||
|
if i+1 < len(locs) {
|
||||||
|
end = locs[i+1][0]
|
||||||
|
}
|
||||||
|
blocks = append(blocks, pageHTML[loc[1]:end])
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBingResults(pageHTML string, count int) []searchResult {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
var results []searchResult
|
var results []searchResult
|
||||||
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
|
for _, block := range splitBingBlocks(pageHTML) {
|
||||||
matches := re.FindAllStringSubmatch(html, -1)
|
|
||||||
for _, m := range matches {
|
|
||||||
if len(results) >= count {
|
if len(results) >= count {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
block := m[1]
|
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
|
||||||
var r searchResult
|
var href, title string
|
||||||
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
|
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
|
||||||
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
|
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||||
r.URL = hm[1]
|
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
|
||||||
|
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||||
}
|
}
|
||||||
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
|
href = bingRealURL(html.UnescapeString(href))
|
||||||
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
|
|
||||||
r.Title = stripTags(tm[1])
|
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
|
||||||
|
var snippet string
|
||||||
|
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
|
||||||
|
snippet = html.UnescapeString(stripTags(m[1]))
|
||||||
|
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
|
||||||
|
snippet = html.UnescapeString(stripTags(m[1]))
|
||||||
}
|
}
|
||||||
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
|
|
||||||
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
|
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
|
||||||
r.Snippet = stripTags(sm[1])
|
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
|
||||||
}
|
continue
|
||||||
if r.URL != "" && r.Title != "" {
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
}
|
||||||
|
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
|
||||||
|
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
|
||||||
|
func bingRealURL(href string) string {
|
||||||
|
href = strings.TrimSpace(href)
|
||||||
|
if href == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
u, err := url.Parse(href)
|
||||||
|
if err != nil {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
raw := u.Query().Get("u")
|
||||||
|
if !strings.HasPrefix(raw, "a1") {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
b64 := raw[2:]
|
||||||
|
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
|
||||||
|
if dec, err := enc.DecodeString(b64); err == nil {
|
||||||
|
s := string(dec)
|
||||||
|
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||||
query := readArg(args, "query", "")
|
query := readArg(args, "query", "")
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@ -660,38 +800,82 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。
|
||||||
|
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
|
||||||
|
// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。
|
||||||
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||||
rawURL := readArg(args, "url", "")
|
rawURL := readArg(args, "url", "")
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return errResult("url is required"), nil
|
return errResult("url is required"), nil
|
||||||
}
|
}
|
||||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
if err := p.ssrfCheck(rawURL); err != nil {
|
||||||
if waitSec > 0 {
|
return errResult(err.Error()), nil
|
||||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
|
||||||
}
|
}
|
||||||
var html string
|
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||||
chromiumPath := "/usr/local/bin/chromium"
|
|
||||||
if _, err := os.Stat(chromiumPath); err == nil {
|
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
|
var out bytes.Buffer
|
||||||
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
if err := cmd.Run(); err != nil {
|
done := make(chan error, 1)
|
||||||
return errResult("chromium: " + err.Error()), nil
|
go func() { done <- cmd.Run() }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return errResult("chromium: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
<-done // 回收子进程避免僵尸
|
||||||
|
return errResult("chromium dump-dom timeout (30s)"), nil
|
||||||
}
|
}
|
||||||
html = out.String()
|
html = out.String()
|
||||||
} else {
|
|
||||||
resp, err := http.Get(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return errResult("http get: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
html = string(body)
|
|
||||||
}
|
|
||||||
title := ""
|
|
||||||
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
|
||||||
title = m[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
text := htmlToText(html)
|
text := htmlToText(html)
|
||||||
origLen := len(text)
|
origLen := len(text)
|
||||||
truncated := origLen > 5000
|
truncated := origLen > 5000
|
||||||
@ -706,18 +890,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
if truncated {
|
if truncated {
|
||||||
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"content": result, "title": title}, nil
|
mode := "backend-tab"
|
||||||
|
if !rendered {
|
||||||
|
mode = "local-dump-dom"
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Interactive Browser Session (CDP) ─────────────────────
|
func cdpReachable(endpoint string) bool {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
resp, err := client.Get(endpoint + "/json/version")
|
||||||
timeoutStr := readArg(args, "timeout", "10m")
|
|
||||||
timeout, err := time.ParseDuration(timeoutStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
timeout = 10 * time.Minute
|
return false
|
||||||
}
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
|
||||||
|
func systemdUnitInstalled() bool {
|
||||||
|
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
|
||||||
|
return err == nil && len(out) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSystemdUnit 尝试 systemctl start(单元已安装但未运行时用)。
|
||||||
|
func startSystemdUnit() error {
|
||||||
|
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址(homeagent-browser.service)。
|
||||||
|
const cdpEndpoint = "http://127.0.0.1:9222"
|
||||||
|
|
||||||
|
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
|
||||||
|
// 返回 (ok, needInstall, err)。
|
||||||
|
func (p *Plugin) ensureBackend() (bool, bool, error) {
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
if systemdUnitInstalled() {
|
||||||
|
if err := startSystemdUnit(); err == nil {
|
||||||
|
// 等待 CDP 就绪(chromium 启动 ~1-3s)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false, fmt.Errorf("browser backend service installed but failed to start")
|
||||||
|
}
|
||||||
|
return false, true, nil // 未安装
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
|
||||||
|
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||||
|
if err := chromedp.Run(tabCtx); err != nil {
|
||||||
|
tabCancel()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tabCtx, tabCancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localSpawnFailback 本地拉起一次性 Chromium(离线机器无法装 systemd 服务的兜底)。
|
||||||
|
// 用临时 profile,登录态不跨会话保留——仅保证功能可用。
|
||||||
|
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
|
||||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
chromedp.Flag("headless", true),
|
chromedp.Flag("headless", true),
|
||||||
chromedp.Flag("disable-gpu", true),
|
chromedp.Flag("disable-gpu", true),
|
||||||
@ -727,23 +964,88 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
if p.proxy != "" {
|
if p.proxy != "" {
|
||||||
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
||||||
}
|
}
|
||||||
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
||||||
ctx, _ := chromedp.NewContext(allocCtx)
|
ctx, _ := chromedp.NewContext(allocCtx)
|
||||||
|
|
||||||
// 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程
|
|
||||||
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
|
|
||||||
if err := chromedp.Run(ctx); err != nil {
|
if err := chromedp.Run(ctx); err != nil {
|
||||||
cancel()
|
cancelAlloc()
|
||||||
return errResult("browser init failed: " + err.Error()), nil
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return allocCtx, cancelAlloc, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||||
|
timeout, err := parseBrowserSessionTimeout(args)
|
||||||
|
if err != nil {
|
||||||
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &BrowserSession{
|
source := readArg(args, "source", "")
|
||||||
allocCtx: allocCtx,
|
if source == "" {
|
||||||
cancel: cancel,
|
source = "default"
|
||||||
ctx: ctx,
|
}
|
||||||
createdAt: time.Now(),
|
|
||||||
timeout: timeout,
|
// 同 source 复用已有标签页
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, s := range p.sessions {
|
||||||
|
if s.shared && s.sessionKey == source && !s.closed {
|
||||||
|
s.mu.Lock()
|
||||||
|
id := s.id
|
||||||
|
cur := s.currentURL
|
||||||
|
s.createdAt = time.Now()
|
||||||
|
s.timeout = timeout
|
||||||
|
closesAt := s.createdAt.Add(timeout)
|
||||||
|
s.mu.Unlock()
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"status": "reused",
|
||||||
|
"url": cur,
|
||||||
|
"timeout": timeout.String(),
|
||||||
|
"closes_at": closesAt.Format(time.RFC3339),
|
||||||
|
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var session *BrowserSession
|
||||||
|
|
||||||
|
// 路径一:systemd 托管的共享后端(主路径)
|
||||||
|
ok, needInstall, berr := p.ensureBackend()
|
||||||
|
if ok {
|
||||||
|
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
|
probe, _ := chromedp.NewContext(remoteCtx)
|
||||||
|
if err := chromedp.Run(probe); err != nil {
|
||||||
|
remoteCancel()
|
||||||
|
return errResult("connect to browser backend failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
|
if err := chromedp.Run(tabCtx); err != nil {
|
||||||
|
remoteCancel()
|
||||||
|
return errResult("open tab failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
session = &BrowserSession{
|
||||||
|
allocCtx: remoteCtx,
|
||||||
|
cancel: tabCancel,
|
||||||
|
ctx: tabCtx,
|
||||||
|
createdAt: time.Now(),
|
||||||
|
timeout: timeout,
|
||||||
|
shared: true,
|
||||||
|
sessionKey: source,
|
||||||
|
}
|
||||||
|
} else if needInstall {
|
||||||
|
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
|
||||||
|
"需要本机有 chromium 二进制(apt install chromium 或等价命令)," +
|
||||||
|
"插件会注册 homeagent-browser.service 并启动。" +
|
||||||
|
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "backend not installed",
|
||||||
|
"need_install": true,
|
||||||
|
"guide": guide,
|
||||||
|
}, nil
|
||||||
|
} else {
|
||||||
|
return errResult("browser backend error: " + berr.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@ -755,7 +1057,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
|
|
||||||
initURL := readArg(args, "url", "")
|
initURL := readArg(args, "url", "")
|
||||||
if initURL != "" {
|
if initURL != "" {
|
||||||
if err := chromedp.Run(ctx,
|
if err := chromedp.Run(session.ctx,
|
||||||
chromedp.Navigate(initURL),
|
chromedp.Navigate(initURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -766,15 +1068,17 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
return errResult("navigate failed: " + err.Error()), nil
|
return errResult("navigate failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
session.currentURL = initURL
|
session.currentURL = initURL
|
||||||
p.sdk.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)
|
closesAt := session.createdAt.Add(timeout)
|
||||||
|
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "created",
|
"status": "created",
|
||||||
"url": initURL,
|
"mode": "shared-backend",
|
||||||
"timeout": timeout.String(),
|
"url": initURL,
|
||||||
|
"timeout": timeout.String(),
|
||||||
|
"closes_at": closesAt.Format(time.RFC3339),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -807,7 +1111,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
|
|||||||
return errResult("navigate failed: " + err.Error()), nil
|
return errResult("navigate failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
s.currentURL = rawURL
|
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
|
return map[string]interface{}{"status": "ok", "url": rawURL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -825,6 +1129,9 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
|||||||
full = v
|
full = v
|
||||||
}
|
}
|
||||||
format := readArg(args, "format", "png")
|
format := readArg(args, "format", "png")
|
||||||
|
if format != "png" {
|
||||||
|
return errResult("仅支持 png 格式"), nil
|
||||||
|
}
|
||||||
var buf []byte
|
var buf []byte
|
||||||
var err error
|
var err error
|
||||||
if full {
|
if full {
|
||||||
@ -837,11 +1144,11 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
|||||||
}
|
}
|
||||||
b64 := base64.StdEncoding.EncodeToString(buf)
|
b64 := base64.StdEncoding.EncodeToString(buf)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"format": format,
|
"format": format,
|
||||||
"size": len(buf),
|
"size": len(buf),
|
||||||
"base64": b64,
|
"base64": b64,
|
||||||
"data_uri": fmt.Sprintf("data:image/%s;base64,%s", format, b64),
|
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -872,11 +1179,11 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
|||||||
html = html[:maxChars] + "\n\n[HTML truncated]"
|
html = html[:maxChars] + "\n\n[HTML truncated]"
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"title": title,
|
"title": title,
|
||||||
"url": currentURL,
|
"url": currentURL,
|
||||||
"html": html,
|
"html": html,
|
||||||
"length": len(html),
|
"length": len(html),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -997,16 +1304,124 @@ func (p *Plugin) cleanupLoop() {
|
|||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
now := time.Now()
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
for id, s := range p.sessions {
|
for id, s := range p.sessions {
|
||||||
if time.Since(s.createdAt) >= s.timeout {
|
s.mu.Lock()
|
||||||
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
|
closesAt := s.createdAt.Add(s.timeout)
|
||||||
|
expired := !now.Before(closesAt)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if expired {
|
||||||
|
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
|
||||||
delete(p.sessions, id)
|
delete(p.sessions, id)
|
||||||
go s.Close()
|
s.Close()
|
||||||
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
// NoMemory:会话生命周期通知,不是记忆内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
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
|
||||||
|
}
|
||||||
|
|||||||
213
example/browser/plugin_test.go
Normal file
213
example/browser/plugin_test.go
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBrowserSessionTimeoutRequiresExplicitValue(t *testing.T) {
|
||||||
|
_, err := parseBrowserSessionTimeout(map[string]interface{}{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "timeout is required") {
|
||||||
|
t.Fatalf("expected required timeout error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBrowserSessionTimeoutAcceptsPositiveDuration(t *testing.T) {
|
||||||
|
got, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": "2h30m"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != 2*time.Hour+30*time.Minute {
|
||||||
|
t.Fatalf("timeout=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBrowserSessionTimeoutRejectsInvalidOrNonPositive(t *testing.T) {
|
||||||
|
for _, value := range []string{"invalid", "0s", "-1m"} {
|
||||||
|
if _, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": value}); err == nil {
|
||||||
|
t.Errorf("timeout %q should be rejected", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserStartReuseResetsExplicitCloseTime(t *testing.T) {
|
||||||
|
p := &Plugin{
|
||||||
|
name: "browser",
|
||||||
|
sessions: map[string]*BrowserSession{
|
||||||
|
"browser_1": {
|
||||||
|
id: "browser_1",
|
||||||
|
shared: true,
|
||||||
|
sessionKey: "qq",
|
||||||
|
createdAt: time.Now().Add(-time.Hour),
|
||||||
|
timeout: time.Minute,
|
||||||
|
currentURL: "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
result, err := p.handleBrowserStart(map[string]interface{}{"source": "qq", "timeout": "3h"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := result.(map[string]interface{})
|
||||||
|
if out["status"] != "reused" || out["timeout"] != "3h0m0s" {
|
||||||
|
t.Fatalf("unexpected result: %#v", out)
|
||||||
|
}
|
||||||
|
s := p.sessions["browser_1"]
|
||||||
|
if s.timeout != 3*time.Hour || s.createdAt.Before(before) {
|
||||||
|
t.Fatalf("deadline not reset: createdAt=%v timeout=%v", s.createdAt, s.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bing 解析器(2026-09 版式)─────────────────────────────
|
||||||
|
//
|
||||||
|
// 背景:旧实现把块内**第一个 <a>** 当标题 —— 拿到的是 Bing 的「来源行」
|
||||||
|
// `deepin.orghttps://www.deepin.org`;摘要正则 `<div class="b_caption">.*?<p>`
|
||||||
|
// 对现代 Bing 命中 0/N(摘要已迁到 p.b_lineclamp*),于是结果「有标题没摘要」,
|
||||||
|
// 模型只好反复换词重搜。夹具 testdata/bing_cn.html 是真实 cn.bing.com 响应裁剪。
|
||||||
|
|
||||||
|
func TestParseBingResultsRealBingHTML(t *testing.T) {
|
||||||
|
page, err := os.ReadFile("testdata/bing_cn.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取夹具失败: %v", err)
|
||||||
|
}
|
||||||
|
results := parseBingResults(string(page), 3)
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Fatalf("应解析出 3 条,实际 %d 条: %+v", len(results), results)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if !strings.HasPrefix(r.URL, "http") {
|
||||||
|
t.Errorf("第 %d 条 URL 不是真实地址: %q", i+1, r.URL)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Title, "http") || strings.Contains(r.Title, "://") {
|
||||||
|
t.Errorf("第 %d 条标题混入了 URL(旧 bug 的典型症状): %q", i+1, r.Title)
|
||||||
|
}
|
||||||
|
if r.Snippet == "" {
|
||||||
|
t.Errorf("第 %d 条没有摘要(旧 bug 的典型症状): %+v", i+1, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 第一条必须与样本里的真实结果一致
|
||||||
|
if results[0].URL != "https://www.deepin.org/" {
|
||||||
|
t.Errorf("第一条 URL 应为 https://www.deepin.org/,实际 %q", results[0].URL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(results[0].Title, "deepin") {
|
||||||
|
t.Errorf("第一条标题不对: %q", results[0].Title)
|
||||||
|
}
|
||||||
|
if len(results[0].Snippet) < 10 || strings.Contains(results[0].Snippet, "://") {
|
||||||
|
t.Errorf("第一条摘要不对(应是有内容的文本): %q", results[0].Snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 块内嵌套 <li>(deep links)时不能截断 —— 旧的 `<li class="b_algo"(?s)(.*?)</li>` 会在此翻车
|
||||||
|
func TestParseBingResultsNestedLiKeepsResult(t *testing.T) {
|
||||||
|
page := `<ol id="b_results"><li class="b_algo" data-id iid=SERP.1>` +
|
||||||
|
`<h2><a href="https://a.example/x" h="ID=SERP,1">真标题</a></h2>` +
|
||||||
|
`<div class="b_caption"><p class="b_lineclamp2">真摘要</p></div>` +
|
||||||
|
`<div><ul><li><a href="https://sub.example/deeplink">子链接</a></li></ul></div>` +
|
||||||
|
`</li><li class="b_algo"><h2><a href="https://b.example/y">第二条</a></h2>` +
|
||||||
|
`<p class="b_lineclamp3">摘要二</p></li></ol>`
|
||||||
|
rs := parseBingResults(page, 5)
|
||||||
|
if len(rs) != 2 {
|
||||||
|
t.Fatalf("应解析 2 条,实际 %d 条: %+v", len(rs), rs)
|
||||||
|
}
|
||||||
|
if rs[0].URL != "https://a.example/x" || rs[0].Title != "真标题" || rs[0].Snippet != "真摘要" {
|
||||||
|
t.Errorf("第一条解析错误: %+v", rs[0])
|
||||||
|
}
|
||||||
|
if rs[1].Title != "第二条" || rs[1].Snippet != "摘要二" {
|
||||||
|
t.Errorf("第二条(无 b_caption,摘要走 b_lineclamp3)解析错误: %+v", rs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBingRealURLDecodesRedirectWrapper(t *testing.T) {
|
||||||
|
// Bing 跳转包装:/ck/a?...&u=a1<base64url>
|
||||||
|
wrapped := "/ck/a?!&&p=abc&u=a1aHR0cHM6Ly93d3cuZGVlcGluLm9yZy96aC9EZWVwaW4v&ntb=1"
|
||||||
|
if got := bingRealURL(wrapped); got != "https://www.deepin.org/zh/Deepin/" {
|
||||||
|
t.Errorf("未解开跳转包装: %q", got)
|
||||||
|
}
|
||||||
|
if got := bingRealURL("https://direct.example/p"); got != "https://direct.example/p" {
|
||||||
|
t.Errorf("直链不应被改动: %q", got)
|
||||||
|
}
|
||||||
|
// 解不开时保守返回原值,不能返回空
|
||||||
|
bad := "/ck/a?u=a1!!!!"
|
||||||
|
if got := bingRealURL(bad); got == "" {
|
||||||
|
t.Errorf("解不开时应保留原值,实际返回空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTripFunc 把任意请求转给本地测试服务器,从而离线测 bingSearch 的完整路径
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
func TestBingSearchReportsParseFailureInsteadOfEmptyResult(t *testing.T) {
|
||||||
|
var seenURL string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("<html><body>no result blocks here</body></html>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
seenURL = r.URL.String()
|
||||||
|
return srv.Client().Transport.RoundTrip(&http.Request{
|
||||||
|
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
|
||||||
|
})
|
||||||
|
})}}
|
||||||
|
|
||||||
|
if _, err := p.bingSearch("任意查询", 5); err == nil {
|
||||||
|
t.Fatal("解析不出结果时必须报错,而不是伪装成「没有结果」")
|
||||||
|
} else if !strings.Contains(err.Error(), "未解析出结果") {
|
||||||
|
t.Errorf("错误信息应说明是解析失败: %v", err)
|
||||||
|
}
|
||||||
|
// 数据源必须是 cn.bing.com(www.bing.com 对程序化请求回 302,拿不到结果块)
|
||||||
|
if !strings.Contains(seenURL, "cn.bing.com") {
|
||||||
|
t.Errorf("应请求 cn.bing.com,实际 %q", seenURL)
|
||||||
|
}
|
||||||
|
if strings.Contains(seenURL, "www.bing.com") {
|
||||||
|
t.Errorf("不应再请求 www.bing.com: %q", seenURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常路径:能解析出结果时返回结果且不报错
|
||||||
|
func TestBingSearchParsesFixtureThroughClient(t *testing.T) {
|
||||||
|
page, err := os.ReadFile("testdata/bing_cn.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取夹具失败: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(page)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return srv.Client().Transport.RoundTrip(&http.Request{
|
||||||
|
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
|
||||||
|
})
|
||||||
|
})}}
|
||||||
|
|
||||||
|
results, err := p.bingSearch("deepin", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("应成功,实际 %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("应返回 2 条(count 生效),实际 %d", len(results))
|
||||||
|
}
|
||||||
|
if results[0].Snippet == "" {
|
||||||
|
t.Errorf("摘要不应为空: %+v", results[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析测试 URL 失败: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
1
example/browser/testdata/bing_cn.html
vendored
Normal file
1
example/browser/testdata/bing_cn.html
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,13 +1,57 @@
|
|||||||
# calendar
|
# calendar · 日历事件
|
||||||
|
|
||||||
calendar plugin
|
事件管理:支持**重复事件**与**多档提醒**。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
```bash
|
| 工具 | 说明 |
|
||||||
plugindev build
|
|---|---|
|
||||||
|
| `calendar_event_add` | 添加事件 |
|
||||||
|
| `calendar_event_list` | 列出即将到来的事件(含日期、时间、重复规则) |
|
||||||
|
| `calendar_event_update` | 更新事件(**只改传入的字段**;会重置提醒状态) |
|
||||||
|
| `calendar_event_delete` | 删除事件(连带**该事件及之后的所有重复实例**) |
|
||||||
|
| `calendar_today` | 今日事件 + 倒计时 |
|
||||||
|
| `calendar_week` | 本周事件,按天分组 |
|
||||||
|
| `calendar_month` | 月历网格,带事件标记点 |
|
||||||
|
| `calendar_search` | 按关键词搜标题 / 地点 / 备注 |
|
||||||
|
|
||||||
|
`calendar_event_add` 的时间格式:`YYYY-MM-DD HH:MM`;只给 `YYYY-MM-DD` 表示全天事件。
|
||||||
|
|
||||||
|
## 重复规则
|
||||||
|
|
||||||
|
`repeat` 取值:
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `none` | 不重复 |
|
||||||
|
| `daily` | 每天 |
|
||||||
|
| `weekday` | 每个工作日 |
|
||||||
|
| `weekly` | 每周 |
|
||||||
|
| `biweekly` | 每两周 |
|
||||||
|
| `monthly` | 每月 |
|
||||||
|
| `yearly` | 每年 |
|
||||||
|
| `lunar_yearly` | **按农历年**(生日、传统节日用) |
|
||||||
|
|
||||||
|
`lunar_yearly` 是刻意加的:农历节日按公历写死会逐年偏移。
|
||||||
|
|
||||||
|
## 提醒
|
||||||
|
|
||||||
|
`remind_before` 单位是**分钟**,可给多个、逗号分隔:
|
||||||
|
|
||||||
|
```
|
||||||
|
15,60,1440 # 提前 15 分钟 + 1 小时 + 1 天
|
||||||
|
0 或留空 # 不提醒
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
到点通过 `InjectInterruptText` 注入提醒,带 `NoMemory: true` ——
|
||||||
|
提醒是瞬时信号,不是记忆内容。通道 `calendar` 同样声明为 NoMemory。
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
## 存储
|
||||||
|
|
||||||
|
事件存为 JSON,插件重启后保留。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
|
|||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar",
|
"name": "calendar",
|
||||||
"name_zh": "日历",
|
"name_zh": "日历",
|
||||||
"name_en": "Calendar",
|
"name_en": "Calendar",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "日历事件管理,支持提醒和重复事件",
|
"description": "日历事件管理,支持提醒和重复事件",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
"tags": [
|
||||||
|
"calendar",
|
||||||
|
"event",
|
||||||
|
"reminder",
|
||||||
|
"schedule"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -15,31 +15,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RepeatNone = "none"
|
RepeatNone = "none"
|
||||||
RepeatDaily = "daily"
|
RepeatDaily = "daily"
|
||||||
RepeatWeekday = "weekday"
|
RepeatWeekday = "weekday"
|
||||||
RepeatWeekly = "weekly"
|
RepeatWeekly = "weekly"
|
||||||
RepeatBiweekly = "biweekly"
|
RepeatBiweekly = "biweekly"
|
||||||
RepeatMonthly = "monthly"
|
RepeatMonthly = "monthly"
|
||||||
RepeatYearly = "yearly"
|
RepeatYearly = "yearly"
|
||||||
RepeatLunarYearly = "lunar_yearly"
|
RepeatLunarYearly = "lunar_yearly"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CalendarEvent struct {
|
type CalendarEvent struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
EndTime string `json:"end_time,omitempty"`
|
||||||
AllDay bool `json:"all_day,omitempty"`
|
AllDay bool `json:"all_day,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
Reminds []int `json:"reminds,omitempty"`
|
Reminds []int `json:"reminds,omitempty"`
|
||||||
RemindAt []int64 `json:"remind_at,omitempty"`
|
RemindAt []int64 `json:"remind_at,omitempty"`
|
||||||
Repeat string `json:"repeat,omitempty"`
|
Repeat string `json:"repeat,omitempty"`
|
||||||
ParentID string `json:"parent_id,omitempty"`
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
Lunar bool `json:"lunar,omitempty"`
|
Lunar bool `json:"lunar,omitempty"`
|
||||||
LunarMonth int `json:"lunar_month,omitempty"`
|
LunarMonth int `json:"lunar_month,omitempty"`
|
||||||
LunarDay int `json:"lunar_day,omitempty"`
|
LunarDay int `json:"lunar_day,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
@ -134,6 +134,18 @@ func readArg[T string | int64 | float64](args map[string]interface{}, key string
|
|||||||
return fallback
|
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 ---
|
// --- Time Helpers ---
|
||||||
|
|
||||||
var shortWeekday = map[time.Weekday]string{
|
var shortWeekday = map[time.Weekday]string{
|
||||||
@ -185,14 +197,14 @@ func daysInLunarYear(year int) int {
|
|||||||
}
|
}
|
||||||
y := lunarInfo[year-1900]
|
y := lunarInfo[year-1900]
|
||||||
sum := 0
|
sum := 0
|
||||||
for i := 0x8000; i > 0; i >>= 1 {
|
for i := 0x8000; i > 0x8; i >>= 1 {
|
||||||
if y&i > 0 {
|
if y&i > 0 {
|
||||||
sum += 30
|
sum += 30
|
||||||
} else {
|
} else {
|
||||||
sum += 29
|
sum += 29
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sum
|
return sum + leapDays(year)
|
||||||
}
|
}
|
||||||
|
|
||||||
func leapMonth(year int) int {
|
func leapMonth(year int) int {
|
||||||
@ -236,11 +248,9 @@ func lunarToSolar(year, month, day int) (time.Time, bool) {
|
|||||||
offset += daysInLunarYear(y)
|
offset += daysInLunarYear(y)
|
||||||
}
|
}
|
||||||
lm := leapMonth(year)
|
lm := leapMonth(year)
|
||||||
|
_ = lm
|
||||||
for m := 1; m < month; m++ {
|
for m := 1; m < month; m++ {
|
||||||
offset += monthDays(year, m)
|
offset += monthDays(year, m)
|
||||||
if m == lm {
|
|
||||||
offset += leapDays(year)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
offset += day - 1
|
offset += day - 1
|
||||||
solar := baseSolar.AddDate(0, 0, offset)
|
solar := baseSolar.AddDate(0, 0, offset)
|
||||||
@ -254,7 +264,7 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t.After(after) || t.Equal(after) {
|
if t.After(after) {
|
||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -266,14 +276,25 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
|||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
|
|
||||||
dataHome := os.Getenv("HOME")
|
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
|
||||||
if dataHome == "" {
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
dataHome = "/tmp"
|
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
|
||||||
|
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()
|
p.loadEvents()
|
||||||
|
|
||||||
|
// 持久化交由 stop handler:内核会在调用 Stop() 之前执行,
|
||||||
|
// 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。
|
||||||
|
s.RegisterStopHandler(p.saveEvents)
|
||||||
|
// 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。
|
||||||
|
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
s.RegisterTool(tp+"event_add", sdk.ToolDef{
|
s.RegisterTool(tp+"event_add", sdk.ToolDef{
|
||||||
@ -341,7 +362,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool(tp+"today", sdk.ToolDef{
|
s.RegisterTool(tp+"today", sdk.ToolDef{
|
||||||
Name: tp + "today", Description: "Show today's events with countdown.",
|
Name: tp + "today", Description: "Show today's events with countdown.",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{},
|
"properties": map[string]interface{}{},
|
||||||
},
|
},
|
||||||
}, p.handleToday)
|
}, p.handleToday)
|
||||||
@ -349,7 +370,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool(tp+"week", sdk.ToolDef{
|
s.RegisterTool(tp+"week", sdk.ToolDef{
|
||||||
Name: tp + "week", Description: "Show this week's events grouped by day.",
|
Name: tp + "week", Description: "Show this week's events grouped by day.",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{},
|
"properties": map[string]interface{}{},
|
||||||
},
|
},
|
||||||
}, p.handleWeek)
|
}, p.handleWeek)
|
||||||
@ -388,7 +409,6 @@ func (p *Plugin) Stop() error {
|
|||||||
p.remindTicker.Stop()
|
p.remindTicker.Stop()
|
||||||
close(p.stopCh)
|
close(p.stopCh)
|
||||||
p.wg.Wait()
|
p.wg.Wait()
|
||||||
p.saveEvents()
|
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
fmt.Printf("[%s] stopped\n", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -411,9 +431,9 @@ func (p *Plugin) checkReminders() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
changed := false
|
changed := false
|
||||||
|
var injectMsgs []string
|
||||||
|
|
||||||
for i := range p.events {
|
for i := range p.events {
|
||||||
e := &p.events[i]
|
e := &p.events[i]
|
||||||
@ -455,7 +475,7 @@ func (p *Plugin) checkReminders() {
|
|||||||
if e.Note != "" {
|
if e.Note != "" {
|
||||||
msg += fmt.Sprintf("\n📝 %s", e.Note)
|
msg += fmt.Sprintf("\n📝 %s", e.Note)
|
||||||
}
|
}
|
||||||
go p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
injectMsgs = append(injectMsgs, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,8 +499,17 @@ func (p *Plugin) checkReminders() {
|
|||||||
pid = e.ParentID
|
pid = e.ParentID
|
||||||
}
|
}
|
||||||
next.ParentID = pid
|
next.ParentID = pid
|
||||||
newEvents = append(newEvents, *next)
|
dup := false
|
||||||
changed = true
|
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 {
|
if len(newEvents) > 0 {
|
||||||
@ -491,6 +520,12 @@ func (p *Plugin) checkReminders() {
|
|||||||
if changed {
|
if changed {
|
||||||
p.saveEventsLocked()
|
p.saveEventsLocked()
|
||||||
}
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
for _, msg := range injectMsgs {
|
||||||
|
// NoMemory:日程到点提醒,不是记忆内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent {
|
func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent {
|
||||||
@ -560,9 +595,7 @@ func (p *Plugin) cleanupPastEvents() {
|
|||||||
keep = append(keep, e)
|
keep = append(keep, e)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if e.Repeat != "" && e.Repeat != RepeatNone {
|
_ = e // 过时重复事件不再保留:next 已由 nextOccurrence 追加
|
||||||
keep = append(keep, e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
p.events = keep
|
p.events = keep
|
||||||
}
|
}
|
||||||
@ -573,6 +606,17 @@ func (p *Plugin) eventsFile() string {
|
|||||||
return filepath.Join(p.dataDir, "events.json")
|
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() {
|
func (p *Plugin) loadEvents() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
@ -616,7 +660,7 @@ func (p *Plugin) saveEventsLocked() {
|
|||||||
NextEventID: p.nextEventID,
|
NextEventID: p.nextEventID,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.eventsFile(), b, 0644)
|
atomicWriteJSON(p.eventsFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: parse remind_before ---
|
// --- Helper: parse remind_before ---
|
||||||
@ -692,10 +736,7 @@ func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error
|
|||||||
note := readArg(args, "note", "")
|
note := readArg(args, "note", "")
|
||||||
remindStr := readArg(args, "remind_before", "")
|
remindStr := readArg(args, "remind_before", "")
|
||||||
reminds := parseReminds(remindStr)
|
reminds := parseReminds(remindStr)
|
||||||
lunar := false
|
lunar := readArgBool(args, "lunar")
|
||||||
if v := readArg(args, "lunar", ""); v == "true" {
|
|
||||||
lunar = true
|
|
||||||
}
|
|
||||||
lunarMonth := int(readArg(args, "lunar_month", int64(0)))
|
lunarMonth := int(readArg(args, "lunar_month", int64(0)))
|
||||||
lunarDay := int(readArg(args, "lunar_day", int64(0)))
|
lunarDay := int(readArg(args, "lunar_day", int64(0)))
|
||||||
|
|
||||||
@ -897,10 +938,12 @@ func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, er
|
|||||||
e.Repeat = v
|
e.Repeat = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v := readArg(args, "lunar", ""); v == "true" {
|
if v, ok := args["lunar"]; ok && v != nil {
|
||||||
e.Lunar = true
|
if b, ok := v.(bool); ok {
|
||||||
} else if v == "false" {
|
e.Lunar = b
|
||||||
e.Lunar = false
|
} 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 {
|
if v := readArg(args, "lunar_month", int64(0)); v > 0 {
|
||||||
e.LunarMonth = int(v)
|
e.LunarMonth = int(v)
|
||||||
@ -1138,3 +1181,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
|
|||||||
}
|
}
|
||||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
100
example/deepsearch/README.md
Normal file
100
example/deepsearch/README.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# 联网检索插件(HomeAgent)
|
||||||
|
|
||||||
|
给 agent 补上**真正的信息检索**能力:检索交给本地 SearXNG(多引擎聚合、结构化 JSON),
|
||||||
|
并补上「读完前 K 篇再回答」的深检索。
|
||||||
|
|
||||||
|
## 为什么需要它(背景)
|
||||||
|
|
||||||
|
agent 原本只有 `browser_*` 那套浏览器工具,联网检索实际只有 `browser_search` 一个入口,而它是
|
||||||
|
**「抓 Bing HTML + 正则解析」**:
|
||||||
|
|
||||||
|
| 缺陷 | 实测结果 |
|
||||||
|
|---|---|
|
||||||
|
| 标题取的是结果块里**第一个 `<a>`** | 拿到的是 Bing 的「来源行」而非标题 → `deepin.orghttps://www.deepin.org` |
|
||||||
|
| 摘要正则 `<div class="b_caption">.*?<p>` | 对现代 Bing **命中 0/10**(摘要已迁到 `p.b_lineclamp*`)→ 结果**完全没有摘要** |
|
||||||
|
| 用 `www.bing.com` | 程序化请求直接 302;`cn.bing.com` 才返回 10 个结果块 |
|
||||||
|
| 单引擎、无兜底、无去重、无站点读取 | 模型只能反复换词重搜(日志里 8 秒 6 连击) |
|
||||||
|
|
||||||
|
结果就是日志里那句用户反馈:**「你的搜索能力好像不太行啊」**。
|
||||||
|
|
||||||
|
## 依赖:本地 SearXNG(由本插件托管)
|
||||||
|
|
||||||
|
插件会**自己管后端**:
|
||||||
|
|
||||||
|
- **启动时**:探 `healthz`;已在跑就**直接接管**(不重启),没跑就 `docker compose up -d` 并等就绪(上限 6s)
|
||||||
|
- **停止时**:跑 `docker compose stop -t 2` 关闭它
|
||||||
|
|
||||||
|
配置项 `manage_searxng`(默认 true)与 `searxng_dir`(默认 `/root/searxng-agent`)控制这套行为;
|
||||||
|
`stop_searxng_on_exit`(默认 true)设 false 可让后端在插件停止后继续跑(**插件重载频繁时建议设 false**,
|
||||||
|
否则每次重载都会把后端重启一遍)。
|
||||||
|
|
||||||
|
### 生命周期契约(依据内核源码,非猜测)
|
||||||
|
|
||||||
|
| 环节 | 内核行为 |
|
||||||
|
|---|---|
|
||||||
|
| 停止插件 | 发 `plugin.stop` → 插件先跑 **RunStopHandlers(LIFO、幂等)** → 再 `Stop()` → `exit(0)` |
|
||||||
|
| 宽限期 | **5 秒**;未退出则直接 SIGKILL —— 所以关闭动作限时 4s(`searxShutdownBudget`) |
|
||||||
|
| stdin 关闭 | 同样会跑 handlers + `Stop()` |
|
||||||
|
| 崩溃/被 kill | 关闭动作不会执行,后端会留在运行态;下次启动探测到就直接接管(**更安全的失败方向**) |
|
||||||
|
| 自动重启 | `SetAutoRestart(true)` 由注入的 runtime 在 `plugin.start` 后经 `lifecycle.autoRestart` **显式上报**内核 |
|
||||||
|
|
||||||
|
### SearXNG 侧配置
|
||||||
|
|
||||||
|
部署在 **.60**,`127.0.0.1:8888`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/searxng-agent/docker-compose.yml # host 网络(要访问宿主 clash)
|
||||||
|
/root/searxng-agent/settings.yml # json 输出 + limiter 关闭 + 出站走 clash
|
||||||
|
```
|
||||||
|
|
||||||
|
两个必须知道的坑:
|
||||||
|
|
||||||
|
1. **`search.formats` 必须含 `json`**,否则 `/search?format=json` 返回 **403**(看起来像网络问题,其实是配置)。
|
||||||
|
2. 该镜像默认 `GRANIAN_PORT=8080`,而 granian 的 `GRANIAN_*` **优先级高于 settings.yml**:
|
||||||
|
.60 上 8080 被 homeagent 占用 → 不改 `SEARXNG_PORT` 就是无休止的 `Address already in use` 崩溃循环。
|
||||||
|
|
||||||
|
实测可用的引擎(2026-09-12):`duckduckgo`、`brave`、`google cse`;`quark` 时好时坏;
|
||||||
|
`baidu`/`google` 经代理出口触发 CAPTCHA,`sogou` 崩溃,`wikidata` 报 HTTP error(已关)。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `deepsearch_search` | 联网检索(首选):标题 + URL + 摘要 + 发布时间,支持 `engines`/`category`/`time_range`/`language`,自动按 URL 去重并按分数排序;会回报**引擎覆盖度与无响应引擎** |
|
||||||
|
| `deepsearch_news` | 新闻检索:`news` 类别 + 默认最近一周;新闻为空时自动回退 general + 时间范围 |
|
||||||
|
| `deepsearch_fetch` | 抓单个网页并抽正文(去脚本/样式/导航),返回标题 + 纯文本,可设截断长度 |
|
||||||
|
| `deepsearch_deep` | **深检索**:检索 → 并行抓前 K 篇正文 → 一次返回「候选清单 + 证据正文」;单篇失败不影响整体 |
|
||||||
|
| `deepsearch_status` | 自检:healthz、json 是否可用、延迟、**哪些引擎真的在返回结果**(检索出问题先跑这个) |
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `searxng_url` | `http://127.0.0.1:8888` | 本地 SearXNG 地址 |
|
||||||
|
| `max_results` | `8` | 默认条数(控制上下文体积) |
|
||||||
|
| `language` | `zh-CN` | 检索语言 |
|
||||||
|
| `safesearch` | `0` | 0 关 / 1 中 / 2 严 |
|
||||||
|
| `request_timeout` | `20` | 单次请求超时(秒) |
|
||||||
|
| `fetch_max_chars` | `4000` | `deepsearch_fetch` 正文上限 |
|
||||||
|
| `proxy` | 空 | 仅作用于本插件直连抓取(搜索出网由 SearXNG 侧负责) |
|
||||||
|
| `user_agent` | Chrome UA | 抓取用 |
|
||||||
|
|
||||||
|
每次调用前重读配置,改完即时生效。
|
||||||
|
|
||||||
|
## 开发与验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 -race ./... # 11 项测试(httptest 打桩 SearXNG)
|
||||||
|
|
||||||
|
# 真实后端联调(默认跳过):跑的就是当初失败的那条查询
|
||||||
|
DEEPSEARCH_LIVE_SEARXNG=http://127.0.0.1:8888 go test -run TestLiveSearxng -v ./...
|
||||||
|
|
||||||
|
hmapdev build # 产出 dist/deep_search_bundle.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
- **知乎等站点对直连抓取返回 403**(反爬),`deepsearch_deep` 会如实标注该篇抓取失败并继续;
|
||||||
|
这类页面请改用浏览器工具(`browser_navigate` + `browser_render`)。
|
||||||
|
- 引擎可用性随出口 IP 与目标站点风控变化;`deepsearch_status` 与每次结果里的「覆盖度」行就是给这个用的。
|
||||||
|
- 未做正文去重/相似度合并:同一事件的多篇转载会各占一条(摘要已能区分)。
|
||||||
21
example/deepsearch/go.mod
Normal file
21
example/deepsearch/go.mod
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
module deepsearch-plugin
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||||
81
example/deepsearch/live_test.go
Normal file
81
example/deepsearch/live_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 真实后端联调(默认跳过,需显式指定地址):
|
||||||
|
//
|
||||||
|
// DEEPSEARCH_LIVE_SEARXNG=http://127.0.0.1:8888 go test -run TestLiveSearxng -v ./...
|
||||||
|
//
|
||||||
|
// 它跑的就是当初失败的场景(日志里那条「你的搜索能力好像不太行啊」对应的查询),
|
||||||
|
// 用来回答一个具体问题:换了后端之后,模型拿到的是不是「带摘要的相关结果」。
|
||||||
|
func TestLiveSearxng(t *testing.T) {
|
||||||
|
base := os.Getenv("DEEPSEARCH_LIVE_SEARXNG")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("未设置 DEEPSEARCH_LIVE_SEARXNG,跳过真实后端联调")
|
||||||
|
}
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch",
|
||||||
|
searxURL: strings.TrimRight(base, "/"),
|
||||||
|
maxItems: 6,
|
||||||
|
language: "zh-CN",
|
||||||
|
fetchMax: 1200,
|
||||||
|
userAgent: defaultUA,
|
||||||
|
}
|
||||||
|
p.ensure()
|
||||||
|
|
||||||
|
// 1) 自检
|
||||||
|
st, err := p.handleStatus(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("status: %v", st)
|
||||||
|
|
||||||
|
// 2) 当初失败的那条查询
|
||||||
|
res, err := p.handleSearch(map[string]interface{}{"query": "深度科技 deepin 开发者 被开除"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
txt := res.(map[string]interface{})["content"].(string)
|
||||||
|
t.Logf("检索结果:\n%s", txt)
|
||||||
|
if !strings.Contains(txt, "摘要:") {
|
||||||
|
t.Errorf("结果里应当有摘要(这正是原实现缺失的东西)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(txt, "覆盖:") {
|
||||||
|
t.Errorf("应报告引擎覆盖度")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 正文抓取(取第一条结果的 URL)
|
||||||
|
var firstURL string
|
||||||
|
for _, line := range strings.Split(txt, "\n") {
|
||||||
|
l := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(l, "http") {
|
||||||
|
firstURL = l
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstURL == "" {
|
||||||
|
t.Fatal("未从结果中解析出 URL")
|
||||||
|
}
|
||||||
|
page, err := p.handleFetch(map[string]interface{}{"url": firstURL, "max_chars": float64(600)})
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("抓取 %s 失败(真实站点有反爬/需 JS 属正常):%v", firstURL, err)
|
||||||
|
} else {
|
||||||
|
body := page.(map[string]interface{})["content"].(string)
|
||||||
|
t.Logf("抓取 %s 正文前 400 字:%s", firstURL, oneLine(body, 400))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 深检索
|
||||||
|
deep, err := p.handleDeep(map[string]interface{}{"query": "统信 UOS 内核工程师 西装 事件", "top_k": float64(2)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deep: %v", err)
|
||||||
|
}
|
||||||
|
dTxt := deep.(map[string]interface{})["content"].(string)
|
||||||
|
if !strings.Contains(dTxt, "候选清单") || !strings.Contains(dTxt, "正文证据") {
|
||||||
|
t.Errorf("深检索输出结构不对")
|
||||||
|
}
|
||||||
|
t.Logf("深检索输出前 800 字:\n%s", oneLine(dTxt, 800))
|
||||||
|
}
|
||||||
17
example/deepsearch/plg.json
Normal file
17
example/deepsearch/plg.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "deepsearch",
|
||||||
|
"name_zh": "联网检索",
|
||||||
|
"name_en": "Deep Search",
|
||||||
|
"version": "1.1.2",
|
||||||
|
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.bin",
|
||||||
|
"tags": [
|
||||||
|
"search",
|
||||||
|
"web",
|
||||||
|
"searxng",
|
||||||
|
"retrieval",
|
||||||
|
"news"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
1038
example/deepsearch/plugin.go
Normal file
1038
example/deepsearch/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
343
example/deepsearch/plugin_test.go
Normal file
343
example/deepsearch/plugin_test.go
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch",
|
||||||
|
searxURL: srv.URL,
|
||||||
|
maxItems: 5,
|
||||||
|
language: "zh-CN",
|
||||||
|
fetchMax: 1000,
|
||||||
|
userAgent: "test-agent",
|
||||||
|
http: srv.Client(),
|
||||||
|
}
|
||||||
|
return p, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一份贴近真实 SearXNG 的响应:含重复 URL、缺摘要、多引擎、无响应引擎
|
||||||
|
const sampleResponse = `{
|
||||||
|
"query": "deepin 被开除",
|
||||||
|
"results": [
|
||||||
|
{"url":"https://www.zhihu.com/question/1?utm_source=x","title":"网传统信内核开发工程师因没穿西服被开除","content":"截止1月9日最新情况…","engines":["duckduckgo","brave"],"score":9.5,"publishedDate":"2026-09-10T00:00:00"},
|
||||||
|
{"url":"https://www.zhihu.com/question/1","title":"网传统信内核开发工程师因没穿西服被开除(重复项)","content":"重复条目","engines":["brave"],"score":1.0},
|
||||||
|
{"url":"https://www.163.com/dy/article/KIQURODQ.html","title":"离谱!传某信创操作系统大厂因西装开除核心开发者","content":"一位负责Linux内核开发的核心工程师…","engines":["brave","quark"],"score":7.2},
|
||||||
|
{"url":"https://bbs.deepin.org.cn/zh","title":"deepin官方论坛","content":"","engines":["duckduckgo"],"score":2.0}
|
||||||
|
],
|
||||||
|
"answers": [],
|
||||||
|
"suggestions": ["deepin 王勇 离职"],
|
||||||
|
"unresponsive_engines": [["baidu","CAPTCHA"],["sogou","unexpected crash"]],
|
||||||
|
"timings": {"search": 1.2}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// 1) 检索:去重 + 按分数排序 + 摘要/覆盖度输出
|
||||||
|
func TestSearchDedupAndFormat(t *testing.T) {
|
||||||
|
var gotQuery url.Values
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/search" {
|
||||||
|
gotQuery = r.URL.Query()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(sampleResponse))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
res, err := p.handleSearch(map[string]interface{}{"query": "deepin 被开除", "count": float64(5)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
if gotQuery.Get("format") != "json" {
|
||||||
|
t.Errorf("必须要求 json 输出,实际 %q", gotQuery.Get("format"))
|
||||||
|
}
|
||||||
|
// SearXNG 的 /search **不认** count/limit(实测两者都返回同样的条数),
|
||||||
|
// 所以「要几条」必须由插件侧截断 —— 也不要再发这种无意义参数(曾以为它生效过)。
|
||||||
|
if gotQuery.Get("limit") != "" || gotQuery.Get("count") != "" {
|
||||||
|
t.Errorf("不应依赖 SearXNG 的条数参数(它不认): %q", gotQuery.Encode())
|
||||||
|
}
|
||||||
|
txt := res.(map[string]interface{})["content"].(string)
|
||||||
|
// utm_source 应被规范化掉,重复项只剩一条
|
||||||
|
if n := strings.Count(txt, "zhihu.com/question/1"); n != 1 {
|
||||||
|
t.Errorf("URL 未正确去重(出现 %d 次):\n%s", n, txt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(txt, "网传统信内核开发工程师") {
|
||||||
|
t.Errorf("缺少标题: %s", txt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(txt, "摘要:") {
|
||||||
|
t.Errorf("应输出摘要: %s", txt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(txt, "baidu(CAPTCHA)") {
|
||||||
|
t.Errorf("应回报无响应引擎(让模型知道覆盖度): %s", txt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(txt, "duckduckgo") || !strings.Contains(txt, "quark") {
|
||||||
|
t.Errorf("应回报引擎覆盖: %s", txt)
|
||||||
|
}
|
||||||
|
// 高分条目应排在前面
|
||||||
|
if strings.Index(txt, "统信内核开发工程师") > strings.Index(txt, "离谱!") {
|
||||||
|
t.Errorf("未按分数排序:\n%s", txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 403(未开 json)必须给出可操作提示,而不是裸错误
|
||||||
|
func TestSearchForbiddenHint(t *testing.T) {
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte("Forbidden"))
|
||||||
|
})
|
||||||
|
_, err := p.handleSearch(map[string]interface{}{"query": "x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("应返回错误")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "403") || !strings.Contains(msg, "formats") {
|
||||||
|
t.Errorf("403 提示应指向 json/limiter 配置,实际: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 空结果:要给出原因与下一步建议
|
||||||
|
func TestSearchEmptyHint(t *testing.T) {
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"query":"x","results":[],"suggestions":["换个词"],"unresponsive_engines":[["google","CAPTCHA"]]}`))
|
||||||
|
})
|
||||||
|
res, err := p.handleSearch(map[string]interface{}{"query": "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
txt := res.(map[string]interface{})["content"].(string)
|
||||||
|
for _, want := range []string{"未返回结果", "google(CAPTCHA)", "换个词", "deepsearch_news"} {
|
||||||
|
if !strings.Contains(txt, want) {
|
||||||
|
t.Errorf("空结果提示缺少 %q: %s", want, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 新闻:应带 categories=news 与 time_range=week;新闻为空时回退 general
|
||||||
|
func TestNewsParamsAndFallback(t *testing.T) {
|
||||||
|
var calls []url.Values
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls = append(calls, r.URL.Query())
|
||||||
|
if r.URL.Query().Get("categories") == "news" {
|
||||||
|
_, _ = w.Write([]byte(`{"query":"n","results":[]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"query":"n","results":[{"url":"https://a.com/1","title":"回退结果","content":"内容","engines":["brave"],"score":1}]}`))
|
||||||
|
})
|
||||||
|
res, err := p.handleNews(map[string]interface{}{"query": "某事"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("新闻为空时应回退 general,实际调用 %d 次", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].Get("categories") != "news" || calls[0].Get("time_range") != "week" {
|
||||||
|
t.Errorf("首次应为 news + week,实际 categories=%q time_range=%q", calls[0].Get("categories"), calls[0].Get("time_range"))
|
||||||
|
}
|
||||||
|
if tmp := res.(map[string]interface{})["content"].(string); !strings.Contains(tmp, "回退结果") {
|
||||||
|
t.Errorf("回退结果未被采用: %s", tmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 正文抽取:去脚本/样式/导航,保留 article
|
||||||
|
func TestFetchExtractsArticle(t *testing.T) {
|
||||||
|
page := `<!doctype html><html><head><title>测试标题 - 站点</title>
|
||||||
|
<style>.x{color:red}</style><script>var secret="SHOULD_NOT_APPEAR";</script></head>
|
||||||
|
<body><nav>导航链接</nav><article>
|
||||||
|
<p>第一段正文,包含关键事实。</p><p>第二段正文。</p>
|
||||||
|
</article><footer>页脚</footer></body></html>`
|
||||||
|
var srvURL string
|
||||||
|
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
})
|
||||||
|
srvURL = srv.URL
|
||||||
|
// 注意:不要用 example.com 之类真实域名——本机 DNS/proxy 会把它们转走,测试会飘
|
||||||
|
res, err := p.handleFetch(map[string]interface{}{"url": srvURL + "/a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
txt := res.(map[string]interface{})["content"].(string)
|
||||||
|
if !strings.Contains(txt, "第一段正文") {
|
||||||
|
t.Errorf("正文丢失: %s", txt)
|
||||||
|
}
|
||||||
|
if strings.Contains(txt, "SHOULD_NOT_APPEAR") {
|
||||||
|
t.Errorf("脚本内容不应出现: %s", txt)
|
||||||
|
}
|
||||||
|
if strings.Contains(txt, "导航链接") || strings.Contains(txt, "页脚") {
|
||||||
|
t.Errorf("导航/页脚应被剥离: %s", txt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(txt, "测试标题") {
|
||||||
|
t.Errorf("标题应被提取: %s", txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 深检索:候选 + 正文证据;单篇失败不应导致整体失败
|
||||||
|
func TestDeepSearch(t *testing.T) {
|
||||||
|
var srvURL string // 处理函数先于 server 存在,故用闭包变量回填
|
||||||
|
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/search":
|
||||||
|
_, _ = w.Write([]byte(`{"query":"d","results":[
|
||||||
|
{"url":"` + srvURL + `/ok1","title":"好文一","content":"摘要一","engines":["brave"],"score":3},
|
||||||
|
{"url":"` + srvURL + `/bad","title":"打不开的","content":"摘要二","engines":["brave"],"score":2},
|
||||||
|
{"url":"` + srvURL + `/ok2","title":"好文二","content":"摘要三","engines":["brave"],"score":1}]}`))
|
||||||
|
case "/ok1", "/ok2":
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte("<html><body><article><p>正文内容 " + r.URL.Path + "</p></article></body></html>"))
|
||||||
|
case "/bad":
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srvURL = srv.URL
|
||||||
|
res, err := p.handleDeep(map[string]interface{}{"query": "d", "top_k": float64(3), "max_chars": float64(500)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
txt := res.(map[string]interface{})["content"].(string)
|
||||||
|
for _, want := range []string{"候选清单", "正文证据", "正文内容 /ok1", "正文内容 /ok2", "抓取失败"} {
|
||||||
|
if !strings.Contains(txt, want) {
|
||||||
|
t.Errorf("深检索输出缺少 %q:\n%s", want, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) 自检:健康检查 + 探测检索 + 引擎覆盖统计
|
||||||
|
func TestStatusReportsEngines(t *testing.T) {
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/healthz" {
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(sampleResponse))
|
||||||
|
})
|
||||||
|
res, err := p.handleStatus(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
if m["healthz"] != 200 {
|
||||||
|
t.Errorf("healthz 应为 200,实际 %v", m["healthz"])
|
||||||
|
}
|
||||||
|
if m["search_ok"] != true {
|
||||||
|
t.Errorf("search_ok 应为 true:%v", m["search_ok"])
|
||||||
|
}
|
||||||
|
engs, ok := m["engines_returning_results"].(map[string]int)
|
||||||
|
if !ok || engs["brave"] == 0 || engs["quark"] == 0 {
|
||||||
|
t.Errorf("引擎统计不正确: %#v", m["engines_returning_results"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) 摘要压成一行并按字符截断(避免巨长摘要吃掉上下文)
|
||||||
|
func TestOneLineTruncate(t *testing.T) {
|
||||||
|
got := oneLine("第一行\n第二行\t第三行", 5)
|
||||||
|
if strings.Contains(got, "\n") {
|
||||||
|
t.Errorf("应为单行: %q", got)
|
||||||
|
}
|
||||||
|
if r := []rune(got); len(r) != 6 { // 5 字符 + 省略号
|
||||||
|
t.Errorf("截断长度不符: %q (%d runes)", got, len(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9) 正文抽取长度上限生效
|
||||||
|
func TestHtmlToTextTruncation(t *testing.T) {
|
||||||
|
long := strings.Repeat("字", 5000)
|
||||||
|
_, text := htmlToText("<html><body><article><p>"+long+"</p></article></body></html>", 100)
|
||||||
|
if !strings.Contains(text, "已截断") {
|
||||||
|
t.Errorf("超长正文应被截断: %d", len([]rune(text)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10) 非 http(s) 协议应被拒绝
|
||||||
|
func TestFetchRejectsBadScheme(t *testing.T) {
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
if _, err := p.handleFetch(map[string]interface{}{"url": "file:///etc/passwd"}); err == nil {
|
||||||
|
t.Fatal("file:// 应被拒绝")
|
||||||
|
}
|
||||||
|
if _, err := p.handleFetch(map[string]interface{}{"url": "javascript:alert(1)"}); err == nil {
|
||||||
|
t.Fatal("javascript: 应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11) raw 模式返回结构化 JSON(排查用)
|
||||||
|
func TestSearchRawMode(t *testing.T) {
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(sampleResponse))
|
||||||
|
})
|
||||||
|
res, err := p.handleSearch(map[string]interface{}{"query": "q", "raw": true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := res.(*searxResponse)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("raw 应返回结构化响应,实际 %T", res)
|
||||||
|
}
|
||||||
|
if len(m.Results) != 4 {
|
||||||
|
t.Errorf("结果数应为 4(raw 不去重),实际 %d", len(m.Results))
|
||||||
|
}
|
||||||
|
if _, err := json.Marshal(m); err != nil {
|
||||||
|
t.Errorf("结构化结果应可序列化: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13) 条数截断:SearXNG 不认条数参数,插件必须自己截,并且**如实说明**给了几条
|
||||||
|
func TestSearchTruncatesToCountAndSaysSo(t *testing.T) {
|
||||||
|
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(sampleResponse)) // 4 条,去重后 3 条
|
||||||
|
})
|
||||||
|
res, err := p.handleSearch(map[string]interface{}{"query": "deepin", "count": float64(2)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
txt := res.(map[string]interface{})["content"].(string)
|
||||||
|
|
||||||
|
// 必须明确区分「命中几条」与「返回几条」:写成「命中 N 条」而实际给了 M<N 条,
|
||||||
|
// 模型会把 N 当成拿到手的条数(实测被 agent 当成事实报给用户)。
|
||||||
|
if !strings.Contains(txt, "命中 3 条,返回前 2 条") {
|
||||||
|
t.Errorf("应如实说明命中数与返回数:\n%s", txt)
|
||||||
|
}
|
||||||
|
// 按 score 排序后的前两条:zhihu(9.5)、163(7.2);第三条 bbs.deepin(2.0) 必须被截掉
|
||||||
|
if !strings.Contains(txt, "统信内核开发工程师") || !strings.Contains(txt, "离谱!") {
|
||||||
|
t.Errorf("前两条(按分数)应在:\n%s", txt)
|
||||||
|
}
|
||||||
|
if strings.Contains(txt, "deepin官方论坛") {
|
||||||
|
t.Errorf("第 3 条(score 最低)超出了 count=2,不该出现:\n%s", txt)
|
||||||
|
}
|
||||||
|
// 条目行数也要正好 2 条(防「头部说 2 条、正文还是全量」)
|
||||||
|
if n := strings.Count(txt, "\n http"); n != 2 {
|
||||||
|
t.Errorf("正文应恰好 2 条,实际 %d 条:\n%s", n, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14) 条数上限:不因为模型要 200 条就真给 200 条
|
||||||
|
func TestLimitResultsCapsAndDefaults(t *testing.T) {
|
||||||
|
p := &Plugin{name: "deepsearch", maxItems: 8}
|
||||||
|
many := make([]searxResult, 30)
|
||||||
|
for i := range many {
|
||||||
|
many[i] = searxResult{URL: "https://e.test/", Title: "t"}
|
||||||
|
}
|
||||||
|
if got := len(p.limitResults(map[string]interface{}{}, many)); got != 8 {
|
||||||
|
t.Errorf("未指定 count 时应取配置的 max_items=8,实际 %d", got)
|
||||||
|
}
|
||||||
|
if got := len(p.limitResults(map[string]interface{}{"count": float64(3)}, many)); got != 3 {
|
||||||
|
t.Errorf("count=3 应返回 3 条,实际 %d", got)
|
||||||
|
}
|
||||||
|
if got := len(p.limitResults(map[string]interface{}{"count": float64(200)}, many)); got != maxSearchResults {
|
||||||
|
t.Errorf("超过上限应收敛到 %d 条,实际 %d", maxSearchResults, got)
|
||||||
|
}
|
||||||
|
// 结果比 count 少时不能造数据
|
||||||
|
few := many[:2]
|
||||||
|
if got := len(p.limitResults(map[string]interface{}{"count": float64(5)}, few)); got != 2 {
|
||||||
|
t.Errorf("结果不足时应原样返回,实际 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
example/deepsearch/searxng.go
Normal file
164
example/deepsearch/searxng.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// SearXNG 生命周期托管:插件启动时拉起搜索后端,插件停止时关闭它。
|
||||||
|
//
|
||||||
|
// 契约依据(内核侧 internal/plugin/proc/*,已逐行核对):
|
||||||
|
// - 内核停止插件:发 `plugin.stop` → 插件先跑 RunStopHandlers(LIFO、幂等)→ 再 Stop() → exit(0)
|
||||||
|
// - 若插件未在 stopGracePeriod(**5 秒**)内退出,内核直接 SIGKILL
|
||||||
|
// - stdin 关闭(内核消失)同样会跑 handlers + Stop()
|
||||||
|
//
|
||||||
|
// 因此这里的关闭动作必须**有界**:searxShutdownBudget 取 4s,留 1s 余量。
|
||||||
|
//
|
||||||
|
// 归属规则(谁拉起谁关):**只有本插件真正执行了 `docker compose up -d` 的实例才算「我们起的」**。
|
||||||
|
// 探活发现已在运行的实例只「接管」——不认领关闭责任。否则同一台机器上的第二个实例
|
||||||
|
// (E2E 测试拉起的插件、另一个 daemon)退出时会把生产后端一起带走:实测就是这条把
|
||||||
|
// 线上搜索服务反复关停的(测试实例用默认配置,测试结束就 `docker compose stop`)。
|
||||||
|
// 若插件是被 kill -9 / OOM 带走的,关闭动作不会执行 —— SearXNG 会留在运行态;
|
||||||
|
// 下次 Start 探测到它在跑就直接接管,这是更安全的失败方向。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cfgManageSearx = "manage_searxng"
|
||||||
|
cfgSearxDir = "searxng_dir"
|
||||||
|
cfgStopOnExit = "stop_searxng_on_exit"
|
||||||
|
|
||||||
|
defaultSearxDir = "/root/searxng-agent"
|
||||||
|
|
||||||
|
searxProbeTimeout = 1500 * time.Millisecond // 单次 healthz 探测
|
||||||
|
searxUpBudget = 20 * time.Second // docker compose up -d 的上限(正常 1s 内返回)
|
||||||
|
searxReadyBudget = 6 * time.Second // up 之后等 healthz 就绪的上限
|
||||||
|
searxShutdownBudget = 4 * time.Second // 必须 < 内核 5s 宽限期
|
||||||
|
)
|
||||||
|
|
||||||
|
// searxBudget 把四个时间预算收拢,便于单测注入短值(否则测试要真等就绪窗口)。
|
||||||
|
type searxBudget struct {
|
||||||
|
probe time.Duration
|
||||||
|
up time.Duration
|
||||||
|
ready time.Duration
|
||||||
|
shutdown time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) budget() searxBudget {
|
||||||
|
b := p.bud
|
||||||
|
if b.probe == 0 {
|
||||||
|
b.probe = searxProbeTimeout
|
||||||
|
}
|
||||||
|
if b.up == 0 {
|
||||||
|
b.up = searxUpBudget
|
||||||
|
}
|
||||||
|
if b.ready == 0 {
|
||||||
|
b.ready = searxReadyBudget
|
||||||
|
}
|
||||||
|
if b.shutdown == 0 {
|
||||||
|
b.shutdown = searxShutdownBudget
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdRunner 抽出来是为了让生命周期逻辑可单测:注入假执行器,不起真容器。
|
||||||
|
type cmdRunner func(ctx context.Context, dir, name string, args ...string) (string, error)
|
||||||
|
|
||||||
|
func defaultRunner(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// searxReachable 探测搜索后端是否可用(只看 healthz,不发检索请求)。
|
||||||
|
func (p *Plugin) searxReachable(timeout time.Duration) bool {
|
||||||
|
if p.searxURL == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := p.http
|
||||||
|
if base == nil {
|
||||||
|
base = &http.Client{}
|
||||||
|
}
|
||||||
|
cl := *base // 复制一份,避免改到共享 client 的超时
|
||||||
|
cl.Timeout = timeout
|
||||||
|
req, err := http.NewRequest(http.MethodGet, p.searxURL+"/healthz", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
resp, err := cl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode < 400
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSearxng 在插件启动时确保搜索后端在跑;已在跑则直接接管,不重启。
|
||||||
|
func (p *Plugin) ensureSearxng() {
|
||||||
|
b := p.budget()
|
||||||
|
if !p.manageSearx {
|
||||||
|
log.Printf("[%s] 未启用 SearXNG 托管(manage_searxng=false),假定 %s 由外部维护", p.name, p.searxURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.searxReachable(b.probe) {
|
||||||
|
// 只接管,不认领:不是我们拉起来的,就不能由我们关掉
|
||||||
|
log.Printf("[%s] SearXNG 已在运行(%s),直接接管(不认领关闭责任)", p.name, p.searxURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), b.up)
|
||||||
|
out, err := p.run(ctx, p.searxDir, "docker", "compose", "up", "-d")
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] 拉起 SearXNG 失败(dir=%s,请检查 manage_searxng/searxng_dir 配置): %v;输出: %s",
|
||||||
|
p.name, p.searxDir, err, oneLine(out, 300))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[%s] 已执行 docker compose up -d(%s):%s", p.name, p.searxDir, oneLine(out, 200))
|
||||||
|
|
||||||
|
deadline := time.Now().Add(b.ready)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if p.searxReachable(800 * time.Millisecond) {
|
||||||
|
log.Printf("[%s] SearXNG 就绪", p.name)
|
||||||
|
p.markSearxOwned()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(600 * time.Millisecond)
|
||||||
|
}
|
||||||
|
log.Printf("[%s] SearXNG 已启动但 %s 内未就绪;首次检索会自动等待", p.name, b.ready)
|
||||||
|
p.markSearxOwned()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) markSearxOwned() {
|
||||||
|
p.searxMu.Lock()
|
||||||
|
p.searxOwned = true
|
||||||
|
p.searxMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownSearxng 关闭搜索后端。幂等,且有界(内核宽限期 5s,这里最多 4s)。
|
||||||
|
func (p *Plugin) shutdownSearxng() {
|
||||||
|
b := p.budget()
|
||||||
|
p.searxMu.Lock()
|
||||||
|
owned := p.searxOwned
|
||||||
|
p.searxOwned = false
|
||||||
|
p.searxMu.Unlock()
|
||||||
|
|
||||||
|
if !owned {
|
||||||
|
return // 不是我们拉起来的 / 已经关过
|
||||||
|
}
|
||||||
|
if !p.manageSearx || !p.stopOnExit {
|
||||||
|
log.Printf("[%s] 保留 SearXNG 运行(stop_searxng_on_exit=false)", p.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), b.shutdown)
|
||||||
|
defer cancel()
|
||||||
|
out, err := p.run(ctx, p.searxDir, "docker", "compose", "stop", "-t", "2")
|
||||||
|
if err != nil {
|
||||||
|
// 故意只记日志:这里再重试就会拖过内核宽限期,被 SIGKILL 更糟
|
||||||
|
log.Printf("[%s] 关闭 SearXNG 失败(忽略): %v;输出: %s", p.name, err, oneLine(out, 200))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[%s] 已关闭 SearXNG", p.name)
|
||||||
|
}
|
||||||
223
example/deepsearch/searxng_test.go
Normal file
223
example/deepsearch/searxng_test.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeCall struct {
|
||||||
|
dir string
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c fakeCall) String() string { return c.name + " " + strings.Join(c.args, " ") }
|
||||||
|
|
||||||
|
// newFakeRunner 记录调用并返回预设结果
|
||||||
|
func newFakeRunner(calls *[]fakeCall, out string, err error) cmdRunner {
|
||||||
|
var mu sync.Mutex
|
||||||
|
return func(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||||
|
mu.Lock()
|
||||||
|
*calls = append(*calls, fakeCall{dir: dir, name: name, args: args})
|
||||||
|
mu.Unlock()
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fastBudget 把就绪窗口压到毫秒级,避免单测真等
|
||||||
|
func fastBudget() searxBudget {
|
||||||
|
return searxBudget{
|
||||||
|
probe: 50 * time.Millisecond,
|
||||||
|
up: time.Second,
|
||||||
|
ready: 200 * time.Millisecond,
|
||||||
|
shutdown: time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 后端没跑 → 应执行 docker compose up -d,并认领关闭责任
|
||||||
|
func TestEnsureSearxngStartsWhenUnreachable(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "Container searxng-agent Started", nil),
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("应恰好拉起一次,实际 %d 次:%v", len(calls), calls)
|
||||||
|
}
|
||||||
|
got := calls[0]
|
||||||
|
if got.name != "docker" || strings.Join(got.args, " ") != "compose up -d" {
|
||||||
|
t.Errorf("命令不对:%s", got)
|
||||||
|
}
|
||||||
|
if got.dir != "/tmp/fake-searx" {
|
||||||
|
t.Errorf("工作目录应为配置的 compose 目录,实际 %q", got.dir)
|
||||||
|
}
|
||||||
|
if !p.searxOwned {
|
||||||
|
t.Error("既然是我们拉起的,就应认领关闭责任")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 后端已在跑 → 不重启,**且不认领关闭责任**
|
||||||
|
//
|
||||||
|
// 这条是关键:同一台机器上会有第二个实例(E2E 测试拉起的插件、另一个 daemon)。
|
||||||
|
// 如果「接管」也算「我拥有」,任一实例退出就会把生产后端关掉 —— 线上实测就是
|
||||||
|
// 测试实例在 teardown 时 `docker compose stop`,把搜索服务反复关停。
|
||||||
|
func TestEnsureSearxngAdoptsRunningBackendWithoutOwning(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/healthz" {
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Errorf("已在跑就不该重启它,实际执行了:%v", calls)
|
||||||
|
}
|
||||||
|
if p.searxOwned {
|
||||||
|
t.Error("不是我们拉起的,就不能认领关闭责任(否则退出时会带走别人的后端)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b) 接管的实例退出时,一个 docker 命令都不能发
|
||||||
|
func TestAdoptedBackendSurvivesShutdown(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
if err := p.Stop(); err != nil {
|
||||||
|
t.Fatalf("Stop: %v", err)
|
||||||
|
}
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Errorf("接管来的后端在退出时必须留着,实际执行了:%v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 关掉托管 → 完全不碰 docker
|
||||||
|
func TestEnsureSearxngDisabled(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: false, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
if len(calls) != 0 || p.searxOwned {
|
||||||
|
t.Errorf("manage_searxng=false 时不该有任何动作:calls=%v owned=%v", calls, p.searxOwned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 拉起失败不能让插件起不来(记日志即可)
|
||||||
|
func TestEnsureSearxngFailureNonFatal(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "Cannot connect to the Docker daemon", errors.New("exit status 1")),
|
||||||
|
}
|
||||||
|
p.ensureSearxng() // 不应 panic
|
||||||
|
if p.searxOwned {
|
||||||
|
t.Error("没拉起来就不该认领关闭责任(否则停止时会去关一个不是我们起的服务)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 停止:关掉我们拉起的后端,且幂等
|
||||||
|
func TestShutdownStopsOwnedBackend(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
runner := newFakeRunner(&calls, "ok", nil)
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: runner,
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
calls = nil
|
||||||
|
|
||||||
|
p.shutdownSearxng()
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("应执行一次 compose stop,实际 %v", calls)
|
||||||
|
}
|
||||||
|
if got := strings.Join(calls[0].args, " "); !strings.HasPrefix(got, "compose stop") {
|
||||||
|
t.Errorf("停止命令不对:%s", got)
|
||||||
|
}
|
||||||
|
if p.searxOwned {
|
||||||
|
t.Error("停止后应清掉认领标记")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.shutdownSearxng() // 幂等:不应再调一次
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Errorf("重复停止应无副作用,实际 %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 不是我们拉起的 → 停止时不许动它
|
||||||
|
func TestShutdownSkippedWhenNotOwned(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxDir: "/tmp/fake-searx", manageSearx: true, stopOnExit: true,
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||||
|
}
|
||||||
|
p.shutdownSearxng()
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Errorf("不该去停一个我们没起的服务:%v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) 配了「停止时保留」→ 认领过也不关
|
||||||
|
func TestShutdownKeepsBackendWhenConfigured(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: false, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
calls = nil
|
||||||
|
p.shutdownSearxng()
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Errorf("stop_searxng_on_exit=false 时不应关闭:%v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) Stop() 自身也要收尾(内核 stdin 关闭路径不会走 stop handler 的注册顺序之外)
|
||||||
|
func TestStopTriggersShutdown(t *testing.T) {
|
||||||
|
var calls []fakeCall
|
||||||
|
p := &Plugin{
|
||||||
|
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
|
||||||
|
manageSearx: true, stopOnExit: true, userAgent: "test",
|
||||||
|
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
|
||||||
|
}
|
||||||
|
p.ensureSearxng()
|
||||||
|
calls = nil
|
||||||
|
if err := p.Stop(); err != nil {
|
||||||
|
t.Fatalf("Stop 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Errorf("Stop 应触发一次关闭,实际 %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
example/editdoc/README.md
Normal file
49
example/editdoc/README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# editdoc · Office 文档编辑
|
||||||
|
|
||||||
|
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
|
||||||
|
|
||||||
|
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
|
||||||
|
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
|
||||||
|
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `file` | 文档路径(必填) |
|
||||||
|
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
|
||||||
|
| `target` | 要查找的文本(`replace_text` 用) |
|
||||||
|
| `replacement` | 替换为的文本(`replace_text` 用) |
|
||||||
|
| `sheet` | 工作表名(xlsx 可选) |
|
||||||
|
| `row` | 行号(`set_cell` / `insert_row` 用) |
|
||||||
|
| `col` | 列号(`set_cell` 用) |
|
||||||
|
| `value` | 单元格值(`set_cell` 用) |
|
||||||
|
|
||||||
|
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
|
||||||
|
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
|
||||||
|
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
|
||||||
|
|
||||||
|
- **需要自备 `edit_doc.py`**:它不在本目录里。
|
||||||
|
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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": "editdoc",
|
||||||
"name_zh": "文档编辑",
|
"name_zh": "文档编辑",
|
||||||
"name_en": "Document Editor",
|
"name_en": "Document Editor",
|
||||||
|
|||||||
@ -4,15 +4,19 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
|
scriptPath string
|
||||||
|
venvPython string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
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 {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
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{
|
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||||
Name: "edit_document",
|
Name: "edit_document",
|
||||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
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)
|
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) {
|
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"
|
if p.venvPython == "" {
|
||||||
pythonBin := "python3"
|
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
|
||||||
if _, err := os.Stat(venvPython); err == nil {
|
}
|
||||||
pythonBin = venvPython
|
if _, err := os.Stat(p.venvPython); err != nil {
|
||||||
|
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
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
|
cmd.Stdout = &out
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, fmt.Errorf("edit document: %w", err)
|
return nil, fmt.Errorf("edit document: %w", err)
|
||||||
|
|||||||
44
example/files/README.md
Normal file
44
example/files/README.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# files · 沙箱文件操作
|
||||||
|
|
||||||
|
读写与编辑文件,**全部操作限制在沙箱目录内**。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `files_read` | 读文件内容,支持 `offset` / `limit` 读大文件 |
|
||||||
|
| `files_write` | 写文件,**自动创建父目录** |
|
||||||
|
| `files_edit` | 按精确字符串替换改文件 |
|
||||||
|
| `files_ls` | 列目录(目录名带 `/` 后缀) |
|
||||||
|
|
||||||
|
`files_edit` 用 `edits[]` 传多组替换,每组 `{old, new}`:
|
||||||
|
|
||||||
|
- 每个 `old` 必须在**原文件**中**恰好出现一次** —— 不唯一会报错,避免改错地方。
|
||||||
|
- 所有替换都针对**原内容**匹配,不要在同一个 `edits` 里写相互重叠的改动。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `dir` | 空 | 允许访问的根目录。留空用默认沙箱(主数据目录下的 `files_sandbox`)。**不建议设为 `/`** |
|
||||||
|
|
||||||
|
## 沙箱实现
|
||||||
|
|
||||||
|
路径校验不止一次,是两道:
|
||||||
|
|
||||||
|
1. **规范化后判断**:`filepath.Abs` + `filepath.Clean`,再用 `withinSandbox`
|
||||||
|
检查结果是否在根目录之下(`/` 作为特例放行)。
|
||||||
|
2. **解析符号链接后再判断**:`filepath.EvalSymlinks` 求出真实路径,**再查一次**沙箱。
|
||||||
|
|
||||||
|
第 2 步是关键:只做第 1 步的话,沙箱内一个指向外部的软链接就能绕过限制
|
||||||
|
(`.../sandbox/link -> /etc`)。报错文案也区分了这两种情况
|
||||||
|
(`path outside sandbox` vs `path escapes sandbox via symlink`)。
|
||||||
|
|
||||||
|
对不存在的路径(`write` 会用到),求真实路径时只对已存在的部分做 `EvalSymlinks`,
|
||||||
|
其余保留为未创建的尾部。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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": "files",
|
||||||
"name_zh": "文件系统",
|
"name_zh": "文件系统",
|
||||||
"name_en": "File System",
|
"name_en": "File System",
|
||||||
|
|||||||
@ -27,24 +27,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.sdk = s
|
p.sdk = s
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "dir",
|
Key: "dir",
|
||||||
Default: "/",
|
Default: "",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
DisplayName: "文件系统根目录",
|
DisplayName: "文件系统根目录",
|
||||||
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox),不建议设为 /",
|
||||||
Category: "files",
|
Category: "files",
|
||||||
})
|
})
|
||||||
|
|
||||||
dir := getSetting[string](s.Settings(), "dir", "/")
|
dir := getSetting[string](s.Settings(), "dir", "")
|
||||||
if strings.HasPrefix(dir, "~/") {
|
if strings.HasPrefix(dir, "~/") {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
dir = filepath.Join(home, dir[2:])
|
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)
|
abs, err := filepath.Abs(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve files.dir: %w", err)
|
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
|
p.filesDir = abs
|
||||||
os.MkdirAll(p.filesDir, 0755)
|
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
@ -143,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) {
|
|||||||
return "", fmt.Errorf("resolve path: %w", err)
|
return "", fmt.Errorf("resolve path: %w", err)
|
||||||
}
|
}
|
||||||
base := filepath.Clean(p.filesDir)
|
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 "", 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.
|
// handleRead implements the read tool.
|
||||||
|
|||||||
28
example/luademo/README.md
Normal file
28
example/luademo/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# luademo
|
||||||
|
|
||||||
|
Lua 插件全功能示例,展示 Lua SDK 的完整能力面(对齐 SDK 1.3.0):
|
||||||
|
|
||||||
|
- **工具注册**:`no_memory` + `context_policy` + `cleaner`(记忆计算层过滤)
|
||||||
|
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
||||||
|
- **通道**:`register_output_channel` / `register_input_channel` / `unregister_output_channel`(def 支持 no_memory/context_policy/cleaner)
|
||||||
|
- **注入**:`inject_text` / `inject_interrupt` / `inject_text_no_memory`、`*_opts`(no_memory/context_policy/cleaner_name/priority)、`inject_input_sync`、`inject_*_media`、`set_tool_blocks`
|
||||||
|
- **数据类 API**:`sdk.memory.*`(含 sentence_text/media_digests)、`sdk.doc.*`(含 insert_with_media)、`sdk.knowledge.*`、`sdk.text_memory.*`(含 attachments)、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`、`sdk.events.*`、`sdk.plugin_mgr.*`
|
||||||
|
- **其他**:`register_api`、`set_auto_restart`
|
||||||
|
|
||||||
|
> `luademo_probe_v2` 巡检 1.1/1.2/1.3 新增面。它**故意不调用** `inject_input_sync`:工具 handler 在 LLM 回合内运行,同步注入会自己等自己(死锁)。
|
||||||
|
|
||||||
|
## 本地独立测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua # 使用 sdk.lua mock,不依赖内核
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
通过插件管理 HTTP API 上传 `.hmap` 包,或解压到 `<data>/plugins/luademo/` 后重启内核。
|
||||||
164
example/luademo/main.lua
Normal file
164
example/luademo/main.lua
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
-- 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)
|
||||||
|
|
||||||
|
-- 工具:1.1/1.2/1.3 新增能力巡检(媒体块 / 注入标志位 / 事件 / 动态通道注销)
|
||||||
|
-- 注意:故意不在这里调用 sdk.inject_input_sync——工具handler 运行在 LLM 回合内,
|
||||||
|
-- 同步注入会等本轮回复,等于自己等自己(死锁)。同步注入只适合事件回调等外部入口。
|
||||||
|
sdk.register_tool("luademo_probe_v2", {
|
||||||
|
description = "Exercise media blocks, inject opts, events and channel unregister",
|
||||||
|
parameters = { type = "object", properties = {} },
|
||||||
|
no_memory = true,
|
||||||
|
context_policy = "prune",
|
||||||
|
}, function(args)
|
||||||
|
local res = {}
|
||||||
|
|
||||||
|
-- 多模态:设置下一轮 tool message 携带的内容块
|
||||||
|
sdk.set_tool_blocks({
|
||||||
|
{ type = "text", text = "luademo media block" },
|
||||||
|
{ type = "image_url", image_url = { url = "https://example.com/x.png", detail = "low" } },
|
||||||
|
})
|
||||||
|
res.set_tool_blocks = "ok"
|
||||||
|
|
||||||
|
-- 注入标志位(零值 opts 与旧三参数等价)
|
||||||
|
sdk.inject_text_opts("luademo", "luademo_in", "opts inject", {
|
||||||
|
no_memory = true, context_policy = "prune",
|
||||||
|
})
|
||||||
|
res.inject_text_opts = "ok"
|
||||||
|
|
||||||
|
-- 带媒体的中断注入
|
||||||
|
sdk.inject_interrupt_media("luademo", "luademo_in", "media inject", {
|
||||||
|
{ type = "audio_url", audio_url = { url = "https://example.com/a.mp3" } },
|
||||||
|
})
|
||||||
|
res.inject_interrupt_media = "ok"
|
||||||
|
|
||||||
|
-- 媒体入记忆:三元组带原句,文档带附件
|
||||||
|
local _, merr = sdk.memory.commit({{
|
||||||
|
subject = "luademo", relation = "shows", object = "image",
|
||||||
|
sentence_text = "luademo shows an image", media_digests = {},
|
||||||
|
}})
|
||||||
|
res.memory_commit_with_sentence = { err = merr }
|
||||||
|
local _, derr = sdk.doc.insert_with_media(
|
||||||
|
{ id = "luademo-media", title = "media", content = "with attachment" },
|
||||||
|
{ { mime = "image/png", name = "x.png", data = "aGVsbG8=" } })
|
||||||
|
res.doc_insert_with_media = { err = derr }
|
||||||
|
|
||||||
|
-- 事件订阅(返回取消订阅函数)
|
||||||
|
local unsub = sdk.events.subscribe("agent_output", function(evt)
|
||||||
|
sdk.log("info", "luademo event: " .. tostring(evt.type))
|
||||||
|
end)
|
||||||
|
res.events_subscribe = type(unsub)
|
||||||
|
if unsub then unsub() end
|
||||||
|
|
||||||
|
-- 插件管理(只读查询)
|
||||||
|
res.plugin_mgr_loaded = type(sdk.plugin_mgr.list_loaded())
|
||||||
|
|
||||||
|
-- 动态输出通道注销
|
||||||
|
sdk.register_output_channel("luademo_dyn", 0, "dynamic", {}, function(a) return { ok = true } end)
|
||||||
|
local _, uerr = sdk.unregister_output_channel("luademo_dyn")
|
||||||
|
res.unregister = { err = uerr }
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
413
example/luademo/sdk.lua
Normal file
413
example/luademo/sdk.lua
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
-- HomeAgent Lua Plugin SDK
|
||||||
|
-- Interface contract between Lua plugins and HomeAgent kernel.
|
||||||
|
-- !impl functions are replaced by Go implementations at runtime.
|
||||||
|
-- Standalone/debug: pure Lua mock implementations are used.
|
||||||
|
-- Usage: local sdk = require("sdk")
|
||||||
|
|
||||||
|
sdk = {}
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- level: "debug" | "info" | "warn" | "error"
|
||||||
|
function sdk.log(level, msg)
|
||||||
|
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
-- handler: function(args) -> result
|
||||||
|
function sdk.register_tool(name, def, handler)
|
||||||
|
print("[lua-plugin] register_tool: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- stage: "on_input" | "pre_action" | "post_action" | ...
|
||||||
|
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
|
||||||
|
function sdk.register_stage(stage, handler, scope)
|
||||||
|
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.register_api(name)
|
||||||
|
print("[lua-plugin] register_api: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
-- handler: function(args) -> result
|
||||||
|
function sdk.register_output_channel(name, caps, desc, def, handler)
|
||||||
|
print("[lua-plugin] register_output_channel: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
function sdk.register_input_channel(name, def)
|
||||||
|
print("[lua-plugin] register_input_channel: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.get_setting(key)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.set_setting(key, value)
|
||||||
|
print("[lua-plugin] set_setting: " .. tostring(key))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_text(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_interrupt: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_text_no_memory(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
|
||||||
|
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
|
||||||
|
function sdk.inject_text_opts(source, channel, text, opts)
|
||||||
|
print("[lua-plugin] inject_text_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_opts(source, channel, text, opts)
|
||||||
|
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
|
||||||
|
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
|
||||||
|
function sdk.inject_input_sync(source, channel, text)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_sync_opts(source, channel, text, opts)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
|
||||||
|
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
|
||||||
|
function sdk.set_tool_blocks(blocks)
|
||||||
|
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- blocks 每项:{ type="text", text="..." }
|
||||||
|
-- | { type="image_url", image_url={ url="...", detail="high" } }
|
||||||
|
-- | { type="audio_url", audio_url={ url="..." } }
|
||||||
|
function sdk.inject_input_media(source, channel, text, blocks)
|
||||||
|
print("[lua-plugin] inject_input_media: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
|
||||||
|
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 同 sdk.inject_input_sync:Lua 中不可用。
|
||||||
|
function sdk.inject_input_media_sync(source, channel, text, blocks)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_media(source, channel, text, blocks)
|
||||||
|
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
|
||||||
|
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
|
||||||
|
function sdk.unregister_output_channel(name) return nil, nil end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- enabled: true/false,崩溃时内核自动拉起
|
||||||
|
function sdk.set_auto_restart(enabled)
|
||||||
|
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============ graph memory ============
|
||||||
|
-- !impl
|
||||||
|
sdk.memory = {}
|
||||||
|
-- !impl
|
||||||
|
-- query: string, depth: number -> {entities={...}, relations={...}}
|
||||||
|
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||||
|
-- !impl
|
||||||
|
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
|
||||||
|
function sdk.memory.commit(triples) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.memory.introspect() return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.memory.merge(source, target) return 0 end
|
||||||
|
-- !impl
|
||||||
|
-- criteria: {key=value}, hard: boolean
|
||||||
|
function sdk.memory.purge(criteria, hard) return 0 end
|
||||||
|
|
||||||
|
-- ============ document memory ============
|
||||||
|
-- !impl
|
||||||
|
sdk.doc = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.doc.query(text, top_k) return {} end
|
||||||
|
-- !impl
|
||||||
|
-- doc: { id=, title=, content= }
|
||||||
|
function sdk.doc.insert(doc) return nil end
|
||||||
|
-- !impl
|
||||||
|
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
|
||||||
|
function sdk.doc.insert_with_media(doc, attachments) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.doc.remove(id) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.doc.stats() return {} end
|
||||||
|
|
||||||
|
-- ============ knowledge ============
|
||||||
|
-- !impl
|
||||||
|
sdk.knowledge = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.knowledge.search(query, limit) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.knowledge.add(tag, content) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.knowledge.list() return {} end
|
||||||
|
|
||||||
|
-- ============ text memory ============
|
||||||
|
-- !impl
|
||||||
|
sdk.text_memory = {}
|
||||||
|
-- !impl
|
||||||
|
-- evt: { timestamp=, role=, content=, channel= }
|
||||||
|
function sdk.text_memory.append(evt) return nil end
|
||||||
|
|
||||||
|
-- ============ llm ============
|
||||||
|
-- !impl
|
||||||
|
sdk.llm = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.llm.list_sources() return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.llm.set_source(name) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.llm.current_source() return nil end
|
||||||
|
|
||||||
|
-- ============ social (只读) ============
|
||||||
|
-- !impl
|
||||||
|
sdk.social = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_person(name) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_network(name, depth) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_relations(name) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.list_persons() return {} end
|
||||||
|
|
||||||
|
-- ============ settings (作用域变体) ============
|
||||||
|
-- !impl
|
||||||
|
sdk.settings = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.get_core(key) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.set_core(key, value) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.list_core(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.list(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
|
||||||
|
-- min=, max=, step=, required=, secret= }
|
||||||
|
function sdk.settings.register_def(def) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.defs(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.dump() return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.plugins() return {} end
|
||||||
|
|
||||||
|
-- ============ events(只读订阅) ============
|
||||||
|
-- !impl
|
||||||
|
-- subscribe(event_type, handler) -> unsubscribe()
|
||||||
|
-- handler 收到 { type=, source=, timestamp=, payload= };
|
||||||
|
-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。
|
||||||
|
sdk.events = {}
|
||||||
|
function sdk.events.subscribe(event_type, handler)
|
||||||
|
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============ plugin_mgr ============
|
||||||
|
-- !impl
|
||||||
|
sdk.plugin_mgr = {}
|
||||||
|
function sdk.plugin_mgr.reload_one(name) return nil end
|
||||||
|
function sdk.plugin_mgr.list_loaded() return {} end
|
||||||
|
function sdk.plugin_mgr.is_disabled(name) return false end
|
||||||
|
|
||||||
|
-- json utils (pure Lua)
|
||||||
|
sdk.json = {}
|
||||||
|
|
||||||
|
function sdk.json.encode(val)
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local function _encode(v)
|
||||||
|
local t = type(v)
|
||||||
|
if t == "string" then
|
||||||
|
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
|
||||||
|
return '"' .. s .. '"'
|
||||||
|
elseif t == "number" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "boolean" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "table" then
|
||||||
|
local keys = {}
|
||||||
|
local is_array = true
|
||||||
|
local maxn = 0
|
||||||
|
for k in pairs(v) do
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
|
||||||
|
is_array = false
|
||||||
|
end
|
||||||
|
if type(k) == "number" and k > maxn then maxn = k end
|
||||||
|
end
|
||||||
|
if is_array and #keys >= maxn then
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, maxn do
|
||||||
|
parts[#parts + 1] = _encode(v[i])
|
||||||
|
end
|
||||||
|
return "[" .. table.concat(parts, ",") .. "]"
|
||||||
|
else
|
||||||
|
local parts = {}
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
|
||||||
|
end
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return _encode(val)
|
||||||
|
end)
|
||||||
|
if ok then return result end
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
|
||||||
|
function sdk.json.decode(str)
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local pos, _end = 1, #str
|
||||||
|
local function skip()
|
||||||
|
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
|
||||||
|
end
|
||||||
|
local function parse()
|
||||||
|
skip()
|
||||||
|
if pos > _end then return nil end
|
||||||
|
local c = str:sub(pos, pos)
|
||||||
|
if c == '"' then
|
||||||
|
local s = {}
|
||||||
|
pos = pos + 1
|
||||||
|
while pos <= _end do
|
||||||
|
local ch = str:sub(pos, pos)
|
||||||
|
if ch == '"' then
|
||||||
|
pos = pos + 1
|
||||||
|
return table.concat(s)
|
||||||
|
elseif ch == '\\' then
|
||||||
|
pos = pos + 1
|
||||||
|
local n = str:sub(pos, pos)
|
||||||
|
if n == '"' then s[#s+1] = '"'
|
||||||
|
elseif n == '\\' then s[#s+1] = '\\'
|
||||||
|
elseif n == '/' then s[#s+1] = '/'
|
||||||
|
elseif n == 'b' then s[#s+1] = '\b'
|
||||||
|
elseif n == 'f' then s[#s+1] = '\f'
|
||||||
|
elseif n == 'n' then s[#s+1] = '\n'
|
||||||
|
elseif n == 'r' then s[#s+1] = '\r'
|
||||||
|
elseif n == 't' then s[#s+1] = '\t'
|
||||||
|
elseif n == 'u' then
|
||||||
|
local hex = str:sub(pos+1, pos+4)
|
||||||
|
pos = pos + 4
|
||||||
|
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
|
||||||
|
end
|
||||||
|
pos = pos + 1
|
||||||
|
else
|
||||||
|
s[#s+1] = ch
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(s)
|
||||||
|
elseif c == 't' then pos = pos + 4; return true
|
||||||
|
elseif c == 'f' then pos = pos + 5; return false
|
||||||
|
elseif c == 'n' then pos = pos + 4; return nil
|
||||||
|
elseif c == '{' then
|
||||||
|
pos = pos + 1; skip()
|
||||||
|
local t = {}
|
||||||
|
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
|
||||||
|
while true do
|
||||||
|
skip(); local k = parse(); skip()
|
||||||
|
if str:sub(pos, pos) == ':' then pos = pos + 1 end
|
||||||
|
skip(); t[k] = parse(); skip()
|
||||||
|
local sep = str:sub(pos, pos)
|
||||||
|
if sep == '}' then pos = pos + 1; return t end
|
||||||
|
if sep == ',' then pos = pos + 1 end
|
||||||
|
end
|
||||||
|
elseif c == '[' then
|
||||||
|
pos = pos + 1; skip()
|
||||||
|
local t = {}
|
||||||
|
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
|
||||||
|
local idx = 1
|
||||||
|
while true do
|
||||||
|
skip(); t[idx] = parse(); idx = idx + 1; skip()
|
||||||
|
local sep = str:sub(pos, pos)
|
||||||
|
if sep == ']' then pos = pos + 1; return t end
|
||||||
|
if sep == ',' then pos = pos + 1 end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local s, e = str:find('^[-%d%.eE]+', pos)
|
||||||
|
if s then
|
||||||
|
local num = tonumber(str:sub(s, e))
|
||||||
|
pos = e + 1
|
||||||
|
return num
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return parse()
|
||||||
|
end)
|
||||||
|
if ok then return result end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- http utils
|
||||||
|
sdk.http = {}
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.http.get(url)
|
||||||
|
print("[lua-plugin] http.get: " .. tostring(url))
|
||||||
|
return {status=200, body='{"mock":true}', headers={}}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.http.post(url, body, content_type)
|
||||||
|
print("[lua-plugin] http.post: " .. tostring(url))
|
||||||
|
return {status=200, body='{"mock":true}', headers={}}
|
||||||
|
end
|
||||||
|
|
||||||
|
return sdk
|
||||||
43
example/memo/README.md
Normal file
43
example/memo/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# memo · 待办与备忘录
|
||||||
|
|
||||||
|
两类条目,行为**刻意不同**:
|
||||||
|
|
||||||
|
| 类型 | 用途 | 是否主动提醒 |
|
||||||
|
|---|---|---|
|
||||||
|
| **待办**(todo) | 有截止概念、需要被催的事 | ✅ 会 |
|
||||||
|
| **备忘录**(memo) | 纯记事,供以后查阅 | ❌ 不会 |
|
||||||
|
|
||||||
|
分开的理由:把"提醒我"和"记一下"混成一类,要么备忘录天天弹、要么待办被忘掉。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `memo_todo_add` | 添加待办(会被主动提醒) |
|
||||||
|
| `memo_todo_complete` | 标记待办完成(不再提醒) |
|
||||||
|
| `memo_todo_list` | 列出未完成待办(含 ID、内容、创建时间) |
|
||||||
|
| `memo_todo_delete` | 删除待办(含已完成的) |
|
||||||
|
| `memo_memo_create` | 创建备忘录(纯记事,不提醒) |
|
||||||
|
| `memo_memo_list` | 列出全部备忘录 |
|
||||||
|
| `memo_memo_delete` | 删除备忘录 |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`p.tp`),上面按默认的 `memo_` 写法列出。
|
||||||
|
|
||||||
|
## 提醒机制
|
||||||
|
|
||||||
|
- 后台 **每 5 分钟**检查一次未完成待办数;有则通过 `InjectInterruptText` 注入一条
|
||||||
|
「注意,你还有 N 条待办未完成,请检查」。
|
||||||
|
- 注入带 **`NoMemory: true`** —— 这是定时提醒,不是记忆内容,不该进向量化。
|
||||||
|
- 通道声明为 **`NoMemory`**(`RegisterInputChannel(p.name, ChannelDef{NoMemory:true})`),
|
||||||
|
理由同上:提醒是瞬时信号。
|
||||||
|
- 另有 `StagePreAction` 钩子,在每轮动作前参与。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
条目落在数据目录的 `todos.json`,插件重启后仍在。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "memo",
|
"name": "memo",
|
||||||
"name_zh": "备忘录",
|
"name_zh": "备忘录",
|
||||||
"name_en": "Memo/Notes",
|
"name_en": "Memo",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
|
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["memo", "todo", "notes"],
|
"tags": [
|
||||||
|
"memo",
|
||||||
|
"todo",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -13,20 +13,31 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Memo struct {
|
// Todo 待办条目:会被主动提醒
|
||||||
|
type Todo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memo 备忘录条目:纯记事,不主动提醒
|
||||||
|
type Memo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
todos []Todo
|
||||||
|
nextTID int64
|
||||||
memos []Memo
|
memos []Memo
|
||||||
nextID int64
|
nextMID int64
|
||||||
filePath string
|
todoPath string
|
||||||
|
memoPath string
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
tp string
|
tp string
|
||||||
}
|
}
|
||||||
@ -37,70 +48,157 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
p.tp = p.name + "_"
|
p.tp = p.name + "_"
|
||||||
p.stopCh = make(chan struct{})
|
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
|
||||||
|
|
||||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||||
if err != nil || dataDirVal == "" {
|
if err != nil || dataDirVal == "" {
|
||||||
dataDirVal = "."
|
dataDirVal = "."
|
||||||
}
|
}
|
||||||
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
|
||||||
p.load()
|
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",
|
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||||
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
|
|
||||||
|
// ── 待办(会被主动提醒)──
|
||||||
|
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
|
||||||
|
Name: p.tp + "todo_add",
|
||||||
|
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
|
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
|
||||||
},
|
},
|
||||||
"required": []string{"content"},
|
"required": []string{"content"},
|
||||||
},
|
},
|
||||||
}, p.handleCreate)
|
}, p.handleTodoAdd)
|
||||||
|
|
||||||
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
|
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
|
||||||
Name: p.tp + "complete",
|
Name: p.tp + "todo_complete",
|
||||||
Description: "将指定ID的备忘标记为已完成。",
|
Description: "将指定ID的待办标记为已完成(不再提醒)。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
|
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||||
},
|
},
|
||||||
"required": []string{"id"},
|
"required": []string{"id"},
|
||||||
},
|
},
|
||||||
}, p.handleComplete)
|
}, p.handleTodoComplete)
|
||||||
|
|
||||||
s.RegisterTool(p.tp+"list", sdk.ToolDef{
|
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
|
||||||
Name: p.tp + "list",
|
Name: p.tp + "todo_list",
|
||||||
Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。",
|
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{},
|
"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)
|
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||||
|
|
||||||
go p.periodicCheck()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
close(p.stopCh)
|
close(p.stopCh)
|
||||||
p.save()
|
p.saveTodos()
|
||||||
|
p.saveMemos()
|
||||||
log.Printf("[%s] stopped", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) load() {
|
func (p *Plugin) loadTodos() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
data, err := os.ReadFile(p.filePath)
|
data, err := os.ReadFile(p.todoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.memos = nil
|
p.todos = []Todo{}
|
||||||
p.nextID = 1
|
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
|
return
|
||||||
}
|
}
|
||||||
var store struct {
|
var store struct {
|
||||||
@ -108,66 +206,82 @@ func (p *Plugin) load() {
|
|||||||
NextID int64 `json:"next_id"`
|
NextID int64 `json:"next_id"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(data, &store) != nil {
|
if json.Unmarshal(data, &store) != nil {
|
||||||
p.memos = nil
|
p.memos = []Memo{}
|
||||||
p.nextID = 1
|
p.nextMID = 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.memos = store.Memos
|
p.memos = store.Memos
|
||||||
p.nextID = store.NextID
|
p.nextMID = store.NextID
|
||||||
if p.memos == nil {
|
if p.memos == nil {
|
||||||
p.memos = []Memo{}
|
p.memos = []Memo{}
|
||||||
}
|
}
|
||||||
if p.nextID < 1 {
|
if p.nextMID < 1 {
|
||||||
p.nextID = 1
|
p.nextMID = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) save() {
|
func (p *Plugin) saveTodos() {
|
||||||
|
p.mu.RLock()
|
||||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
"memos": p.memos,
|
"todos": p.todos,
|
||||||
"next_id": p.nextID,
|
"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()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
n := 0
|
n := 0
|
||||||
for _, m := range p.memos {
|
for _, t := range p.todos {
|
||||||
if !m.Done {
|
if !t.Done {
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) pendingMemos() []Memo {
|
func (p *Plugin) pendingTodos() []Todo {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
var out []Memo
|
var out []Todo
|
||||||
for _, m := range p.memos {
|
for _, t := range p.todos {
|
||||||
if !m.Done {
|
if !t.Done {
|
||||||
out = append(out, m)
|
out = append(out, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
|
||||||
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||||
n := p.pendingCount()
|
n := p.pendingTodoCount()
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ctx.Lock()
|
ctx.Lock()
|
||||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
|
||||||
})
|
})
|
||||||
ctx.Unlock()
|
ctx.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
|
||||||
func (p *Plugin) periodicCheck() {
|
func (p *Plugin) periodicCheck() {
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@ -176,19 +290,125 @@ func (p *Plugin) periodicCheck() {
|
|||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
n := p.pendingCount()
|
n := p.pendingTodoCount()
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
p.sdk.InjectInterruptText(p.name, p.name,
|
// NoMemory:这是定时提醒,不是记忆内容。
|
||||||
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
content, _ := args["content"].(string)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return errorResult("content is required"), nil
|
return errorResult("content is required"), nil
|
||||||
@ -196,23 +416,22 @@ func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error)
|
|||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
memo := Memo{
|
memo := Memo{
|
||||||
ID: p.nextID,
|
ID: p.nextMID,
|
||||||
Content: content,
|
Content: content,
|
||||||
CreatedAt: time.Now().Unix(),
|
CreatedAt: time.Now().Unix(),
|
||||||
Done: false,
|
|
||||||
}
|
}
|
||||||
p.nextID++
|
p.nextMID++
|
||||||
p.memos = append(p.memos, memo)
|
p.memos = append(p.memos, memo)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
p.save()
|
p.saveMemos()
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
|
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
|
||||||
"id": memo.ID,
|
"id": memo.ID,
|
||||||
}, nil
|
}, 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)
|
id, ok := args["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errorResult("id is required"), nil
|
return errorResult("id is required"), nil
|
||||||
@ -221,8 +440,8 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
found := false
|
found := false
|
||||||
for i := range p.memos {
|
for i := range p.memos {
|
||||||
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
|
if p.memos[i].ID == int64(id) {
|
||||||
p.memos[i].Done = true
|
p.memos = append(p.memos[:i], p.memos[i+1:]...)
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -230,30 +449,33 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
if !found {
|
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{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
|
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
|
||||||
memos := p.pendingMemos()
|
p.mu.RLock()
|
||||||
|
memos := append([]Memo{}, p.memos...)
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
if len(memos) == 0 {
|
if len(memos) == 0 {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": "暂无未完成的备忘",
|
"content": "暂无备忘录",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, m := range memos {
|
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 {
|
if i > 0 {
|
||||||
sb.WriteString("\n")
|
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{}{
|
return map[string]interface{}{
|
||||||
@ -270,5 +492,24 @@ func errorResult(msg string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
return &Plugin{name: name}, nil
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
30
example/music/README.md
Normal file
30
example/music/README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# music · 音乐搜索
|
||||||
|
|
||||||
|
按关键词搜歌、按 ID 查歌词(数据来自网易云音乐公开接口)。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `music_search` | 按关键词搜歌,返回歌曲列表(含歌曲 ID) |
|
||||||
|
| `music_lyrics` | 按歌曲 ID 取歌词 |
|
||||||
|
|
||||||
|
典型两段式用法:先 `music_search` 拿 ID,再 `music_lyrics` 取词。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- 请求打的是 `https://music.163.com/api/...`,并固定带上 `Referer: https://music.163.com/` ——
|
||||||
|
该接口对缺少来源头的请求会拒绝。
|
||||||
|
- 是**只读**插件:不下载音频、不写本地文件,因此没有需要清理的副作用。
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
- 依赖第三方(网易云)公开接口,其可用性与返回结构不受本插件控制;
|
||||||
|
接口变动时可能返回空列表,而不是报错。
|
||||||
|
- 仅覆盖"搜索 + 歌词",不含播放地址解析。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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": "music",
|
||||||
"name_zh": "音乐搜索",
|
"name_zh": "音乐搜索",
|
||||||
"name_en": "Music Search",
|
"name_en": "Music Search",
|
||||||
|
|||||||
40
example/ocr/README.md
Normal file
40
example/ocr/README.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# ocr · 图片文字识别
|
||||||
|
|
||||||
|
从图片里提取文字(中英文),基于 [Tesseract](https://github.com/tesseract-ocr/tesseract) OCR 引擎。
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
需要系统里装有 `tesseract` 可执行文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
apt install tesseract-ocr tesseract-ocr-chi-sim
|
||||||
|
```
|
||||||
|
|
||||||
|
中文识别需要 `chi_sim` 语言包;缺它时中文会识别成乱码而非报错。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ocr_ocr_image` | 对图片做 OCR,返回识别文本 |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `image_url` | 图片的 HTTP/HTTPS 地址(与 `image_data` 二选一) |
|
||||||
|
| `image_data` | 图片的 base64 数据,**不含** `data:image/...` 前缀(与 `image_url` 二选一) |
|
||||||
|
| `language` | 识别语言,默认 `chi_sim+eng`;可选 `chi_sim` / `eng` / `chi_sim+eng` |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- 传入的图先落到临时目录,OCR 完 `defer os.RemoveAll` 清掉,不残留。
|
||||||
|
- 调用参数固定 `--psm 3`(全自动页面分割),适合截图与常规排版图片;对单行小图或竖排文本效果会下降。
|
||||||
|
- **`Cleaner`**:工具返回的是 JSON(含 `text`、`language` 等字段),进记忆计算前只取 `text` 正文 —— 否则 JSON 结构本身会参与向量化。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -4,4 +4,4 @@ go 1.25.0
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.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": "ocr",
|
||||||
"name_zh": "OCR 文字识别",
|
"name_zh": "OCR 文字识别",
|
||||||
"name_en": "OCR Text Recognition",
|
"name_en": "OCR Text Recognition",
|
||||||
|
|||||||
47
example/plugindev/README.md
Normal file
47
example/plugindev/README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# plugindev — 插件开发工具链(Agent 可调用)
|
||||||
|
|
||||||
|
把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程,
|
||||||
|
不需要人来敲命令行:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugindev_init 生成工程骨架(等价 hmapdev init <name> [--lua])
|
||||||
|
↓ 改 plugin.go
|
||||||
|
plugindev_build 构建打包(等价在该目录 hmapdev build)→ dist/*.hmap
|
||||||
|
↓
|
||||||
|
plugin_install 安装(用 path 指向刚构建出的 .hmap,overwrite=true 表示原地更新)
|
||||||
|
↓
|
||||||
|
plgreload 重载生效
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 参数 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** |
|
||||||
|
| `plugindev_init` | `name`、`lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` |
|
||||||
|
| `plugindev_build` | `dir`、`target` | 在工程目录构建打包;产物路径会在返回里给出 |
|
||||||
|
| `plugindev_sdk` | `action`、`version`、`from` | SDK 版本管理(list/current/path/latest/install/use);`from` 可指向本地 SDK 源码 |
|
||||||
|
| `plugindev_projects` | — | 列出工作区里已有工程与产物 |
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev` → `/root/go/bin/hmapdev` |
|
||||||
|
| `workspace_dir` | `<data_dir>/plugindev` | `plugindev_init` 生成工程的默认目录 |
|
||||||
|
| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 |
|
||||||
|
|
||||||
|
## 前置:装 hmapdev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <sdk-repo>/tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev .
|
||||||
|
hmapdev version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全边界(都在实现里,不只写在文档里)
|
||||||
|
|
||||||
|
- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接;
|
||||||
|
- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建),
|
||||||
|
避免把这个工具变成对任意目录跑构建;
|
||||||
|
- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB,直接回灌会撑爆模型上下文);
|
||||||
|
- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。
|
||||||
7
example/plugindev/go.mod
Normal file
7
example/plugindev/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module plugindev
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||||
11
example/plugindev/main.go
Normal file
11
example/plugindev/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/plugindev/plg.json
Normal file
19
example/plugindev/plg.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "plugindev",
|
||||||
|
"name_zh": "插件开发工具链",
|
||||||
|
"name_en": "Plugin Dev Toolchain",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"plugindev",
|
||||||
|
"toolchain",
|
||||||
|
"developer"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
455
example/plugindev/plugin.go
Normal file
455
example/plugindev/plugin.go
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// plugindev:把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。
|
||||||
|
//
|
||||||
|
// 为什么需要它:hmapdev 是"给人和 CI 用"的命令行工具。做成插件后,Agent 能自己:
|
||||||
|
// plugindev_init(脚手架)→ plugindev_build(构建出 .hmap)→ plugin_install(安装)→ plgreload
|
||||||
|
// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。
|
||||||
|
//
|
||||||
|
// 安全边界(都在实现里落实,不只写在描述里):
|
||||||
|
// - 只有 **hmapdev 一个可执行文件**会被 exec(不接受任意命令/参数拼接);
|
||||||
|
// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json),
|
||||||
|
// 避免把一个 `hmapdev build` 变成对任意目录的操作;
|
||||||
|
// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名
|
||||||
|
// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名);
|
||||||
|
// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBuildTimeout = 10 * time.Minute
|
||||||
|
maxOutputChars = 6000
|
||||||
|
)
|
||||||
|
|
||||||
|
// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。
|
||||||
|
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
|
||||||
|
hmapdev string // 解析到的 hmapdev 可执行文件路径
|
||||||
|
workspace string // 默认工作区(生成的工程落在这里)
|
||||||
|
timeout time.Duration // 单次 hmapdev 调用的超时
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径",
|
||||||
|
Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "workspace_dir", Type: "string", DisplayName: "工程工作区",
|
||||||
|
Description: "plugindev_init 生成工程的默认目录。留空则用 <data_dir>/plugindev",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)",
|
||||||
|
Description: "单次 hmapdev 调用的超时上限",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
p.hmapdev = p.resolveHmapdev()
|
||||||
|
p.workspace = p.resolveWorkspace()
|
||||||
|
p.timeout = defaultBuildTimeout
|
||||||
|
if v, _ := s.Settings().Get("build_timeout_sec"); v != nil {
|
||||||
|
if n, ok := toInt(v); ok && n > 0 {
|
||||||
|
p.timeout = time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_status", sdk.ToolDef{
|
||||||
|
Name: "plugindev_status",
|
||||||
|
Description: "查看插件开发工具链状态:hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
}, p.handleStatus)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_init", sdk.ToolDef{
|
||||||
|
Name: "plugindev_init",
|
||||||
|
Description: "生成一个新的插件工程骨架(等价于 `hmapdev init <name> [--lua]`)。生成后在返回的目录里改 plugin.go,再用 plugindev_build 构建。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"name": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例:my_plugin",
|
||||||
|
},
|
||||||
|
"lang": map[string]interface{}{
|
||||||
|
"type": "string", "description": "go(默认)或 lua",
|
||||||
|
},
|
||||||
|
"dir": map[string]interface{}{
|
||||||
|
"type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"name"},
|
||||||
|
},
|
||||||
|
}, p.handleInit)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_build", sdk.ToolDef{
|
||||||
|
Name: "plugindev_build",
|
||||||
|
Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"dir": map[string]interface{}{
|
||||||
|
"type": "string", "description": "插件工程目录(必须含 plg.json)",
|
||||||
|
},
|
||||||
|
"target": map[string]interface{}{
|
||||||
|
"type": "string", "description": "构建目标,留空 = native(当前平台)。例:linux/amd64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"dir"},
|
||||||
|
},
|
||||||
|
}, p.handleBuild)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_sdk", sdk.ToolDef{
|
||||||
|
Name: "plugindev_sdk",
|
||||||
|
Description: "管理插件 SDK 版本(hmapdev sdk 子命令):list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录)、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string", "description": "list | current | path | latest | install | use",
|
||||||
|
},
|
||||||
|
"version": map[string]interface{}{
|
||||||
|
"type": "string", "description": "install/use 的版本号,如 v1.3.0",
|
||||||
|
},
|
||||||
|
"from": map[string]interface{}{
|
||||||
|
"type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
},
|
||||||
|
}, p.handleSDK)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_projects", sdk.ToolDef{
|
||||||
|
Name: "plugindev_projects",
|
||||||
|
Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
}, p.handleProjects)
|
||||||
|
|
||||||
|
log.Printf("[plugindev] 就绪:hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
// ---------------- 工具实现 ----------------
|
||||||
|
|
||||||
|
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"hmapdev": fallback(p.hmapdev, ""),
|
||||||
|
"workspace": p.workspace,
|
||||||
|
}
|
||||||
|
if p.hmapdev == "" {
|
||||||
|
out["available"] = false
|
||||||
|
out["hint"] = "未找到 hmapdev。请安装:go build -o /usr/local/bin/hmapdev <sdk>/tools/hmapdev"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out["available"] = true
|
||||||
|
if txt, err := p.run(nil, ""); err == nil {
|
||||||
|
out["version"] = strings.TrimSpace(txt)
|
||||||
|
} else {
|
||||||
|
out["error"] = err.Error()
|
||||||
|
}
|
||||||
|
if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil {
|
||||||
|
out["sdk_current"] = strings.TrimSpace(txt)
|
||||||
|
}
|
||||||
|
if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil {
|
||||||
|
out["sdk_path"] = strings.TrimSpace(txt)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) {
|
||||||
|
name, _ := args["name"].(string)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if !namePattern.MatchString(name) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64(它会进 LLM 工具名,违规会让整条请求被上游拒绝)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
dir, _ := args["dir"].(string)
|
||||||
|
if dir == "" {
|
||||||
|
dir = p.workspace
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil
|
||||||
|
}
|
||||||
|
cmd := []string{"init", name}
|
||||||
|
if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") {
|
||||||
|
cmd = append(cmd, "--lua")
|
||||||
|
}
|
||||||
|
txt, err := p.run(cmd, dir)
|
||||||
|
res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) {
|
||||||
|
dir, _ := args["dir"].(string)
|
||||||
|
if dir == "" {
|
||||||
|
return map[string]interface{}{"error": "dir 不能为空"}, nil
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
// 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。
|
||||||
|
manifest := filepath.Join(abs, "plg.json")
|
||||||
|
if _, err := os.Stat(manifest); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("%s 不是插件工程(缺 plg.json);用 plugindev_init 先建一个", abs),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
cmd := []string{"build"}
|
||||||
|
if target, _ := args["target"].(string); strings.TrimSpace(target) != "" {
|
||||||
|
cmd = append(cmd, strings.TrimSpace(target))
|
||||||
|
}
|
||||||
|
txt, runErr := p.run(cmd, abs)
|
||||||
|
res := map[string]interface{}{"output": txt, "project_dir": abs}
|
||||||
|
if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 {
|
||||||
|
res["artifacts"] = pkgs
|
||||||
|
res["next"] = "用 plugin_install 安装本地产物(path 指向上面 artifacts 里的 .hmap),然后 plgreload"
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
res["error"] = runErr.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) {
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
action = strings.TrimSpace(action)
|
||||||
|
switch action {
|
||||||
|
case "list", "current", "path", "latest":
|
||||||
|
txt, err := p.run([]string{"sdk", action}, "")
|
||||||
|
res := map[string]interface{}{"output": txt}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
case "install":
|
||||||
|
version, _ := args["version"].(string)
|
||||||
|
from, _ := args["from"].(string)
|
||||||
|
cmd := []string{"sdk", "install"}
|
||||||
|
if strings.TrimSpace(from) != "" {
|
||||||
|
cmd = append(cmd, "--from", strings.TrimSpace(from))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(version) != "" {
|
||||||
|
cmd = append(cmd, strings.TrimSpace(version))
|
||||||
|
} else if strings.TrimSpace(from) == "" {
|
||||||
|
cmd = append(cmd, "latest")
|
||||||
|
}
|
||||||
|
txt, err := p.run(cmd, "")
|
||||||
|
res := map[string]interface{}{"output": txt}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
case "use":
|
||||||
|
version, _ := args["version"].(string)
|
||||||
|
if strings.TrimSpace(version) == "" {
|
||||||
|
return map[string]interface{}{"error": "use 需要 version"}, nil
|
||||||
|
}
|
||||||
|
txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "")
|
||||||
|
res := map[string]interface{}{"output": txt}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
default:
|
||||||
|
return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
|
||||||
|
entries, err := os.ReadDir(p.workspace)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil
|
||||||
|
}
|
||||||
|
var out []map[string]interface{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(p.workspace, e.Name())
|
||||||
|
projects := []string{dir}
|
||||||
|
// 有些工程会被生成到子目录里(hmapdev init 支持指定目录),这里只看一层
|
||||||
|
for _, sub := range projects {
|
||||||
|
if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := map[string]interface{}{"name": e.Name(), "dir": sub}
|
||||||
|
if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" {
|
||||||
|
item["version"] = v
|
||||||
|
}
|
||||||
|
if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 {
|
||||||
|
item["artifacts"] = pkgs
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
|
||||||
|
return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 基础设施 ----------------
|
||||||
|
|
||||||
|
// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。
|
||||||
|
func (p *Plugin) run(args []string, dir string) (string, error) {
|
||||||
|
if p.hmapdev == "" {
|
||||||
|
return "", fmt.Errorf("未找到 hmapdev 可执行文件")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, p.hmapdev, args...)
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
// 继承环境(Go 工具链需要 GOCACHE/GOPATH/PATH 等)。
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
txt := truncateOutput(string(out))
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return txt, fmt.Errorf("hmapdev %s 超时(%s)", strings.Join(args, " "), p.timeout)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return txt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。
|
||||||
|
func (p *Plugin) resolveHmapdev() string {
|
||||||
|
if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil {
|
||||||
|
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||||
|
if _, err := os.Stat(strings.TrimSpace(s)); err == nil {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("hmapdev"); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} {
|
||||||
|
if _, err := os.Stat(cand); err == nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveWorkspace:配置项 → <data_dir>/plugindev → ./plugindev。
|
||||||
|
func (p *Plugin) resolveWorkspace() string {
|
||||||
|
if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil {
|
||||||
|
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||||
|
ws := strings.TrimSpace(s)
|
||||||
|
_ = os.MkdirAll(ws, 0o755)
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil {
|
||||||
|
if dd, _ := v.(string); dd != "" {
|
||||||
|
ws := filepath.Join(dd, "plugindev")
|
||||||
|
_ = os.MkdirAll(ws, 0o755)
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws := "plugindev"
|
||||||
|
_ = os.MkdirAll(ws, 0o755)
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateOutput 截断长输出:构建日志动辄几百 KB,直接返回会灌爆模型上下文。
|
||||||
|
func truncateOutput(s string) string {
|
||||||
|
if len(s) <= maxOutputChars {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
head := s[:maxOutputChars/2]
|
||||||
|
tail := s[len(s)-maxOutputChars/2:]
|
||||||
|
return fmt.Sprintf("%s\n…(输出被截断,共 %d 字节)…\n%s", head, len(s), tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。
|
||||||
|
func listHmap(dir string) []string {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, filepath.Join(dir, e.Name()))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPlgVersion(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
i := strings.Index(s, `"version"`)
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := s[i:]
|
||||||
|
j := strings.Index(rest, ":")
|
||||||
|
if j < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest = strings.TrimSpace(rest[j+1:])
|
||||||
|
rest = strings.TrimPrefix(rest, `"`)
|
||||||
|
if k := strings.Index(rest, `"`); k > 0 {
|
||||||
|
return rest[:k]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt(v interface{}) (int, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n, true
|
||||||
|
case int64:
|
||||||
|
return int(n), true
|
||||||
|
case float64:
|
||||||
|
return int(n), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallback(s, def string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user