mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 09:28:04 +00:00
Compare commits
19 Commits
release/v1
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d14afa8d6 | |||
| 87241bc4ff | |||
| 535922c6a0 | |||
| fe66f72b9c | |||
| 0a4eb76542 | |||
| da046b2520 | |||
| 4cb3a0bda4 | |||
| e50bffa34f | |||
| 934eb4da7d | |||
| 4f4a03d368 | |||
| 4482235312 | |||
| 4cf2df5be6 | |||
| ebd700eaf9 | |||
| 7c0b7a1fb0 | |||
| 8c10b7ecc7 | |||
| fcb7490f63 | |||
| 9206353858 | |||
| 83a54f321e | |||
| b93fe6b878 |
58
README.md
58
README.md
@ -4,7 +4,7 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
||||
|
||||
## 版本与兼容性
|
||||
|
||||
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。
|
||||
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
|
||||
|
||||
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||
|
||||
@ -12,11 +12,15 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
||||
|---|---|
|
||||
| 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.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 版本)。
|
||||
@ -28,6 +32,40 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
||||
所以 `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` 能声明;
|
||||
@ -84,11 +122,15 @@ type Plugin interface {
|
||||
|
||||
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
||||
|
||||
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
|
||||
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**,
|
||||
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
|
||||
|
||||
| 分类 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,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 调用 |
|
||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||
@ -135,6 +177,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
|
||||
|
||||
### 输出通道
|
||||
|
||||
> ⚠️ **命名约束(会进 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)
|
||||
```
|
||||
|
||||
24
README_EN.md
24
README_EN.md
@ -4,7 +4,7 @@ Plugin development SDK for building intelligent plugins that interact with the H
|
||||
|
||||
## Version and Compatibility
|
||||
|
||||
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
||||
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`**:
|
||||
|
||||
@ -12,13 +12,33 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
||||
|---|---|
|
||||
| 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 onward | 1.2.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,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "a2a",
|
||||
"name_zh": "A2A 代理通信",
|
||||
"name_en": "A2A Agent Communication",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
@ -24,8 +24,8 @@ type Plugin struct {
|
||||
|
||||
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
|
||||
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
|
||||
sessMu sync.Mutex
|
||||
sessions map[string]*a2aSession
|
||||
sessMu sync.Mutex
|
||||
sessions map[string]*a2aSession
|
||||
}
|
||||
|
||||
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
|
||||
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.sessions = make(map[string]*a2aSession)
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||
tp := p.name + "_"
|
||||
|
||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
||||
@ -66,7 +69,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Key: "listen", Default: "127.0.0.1:12000",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
// Outbound: query + discover
|
||||
@ -75,10 +78,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||
},
|
||||
"required": []string{"agent_url", "query"},
|
||||
},
|
||||
@ -287,7 +290,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
Query string `json:"query,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Message *struct {
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
@ -347,7 +350,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
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.History = sess.History[len(sess.History)-maxSessionTurns*2:]
|
||||
}
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
@ -357,11 +360,11 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||
"status": "completed",
|
||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||
"status": "completed",
|
||||
"session_id": sessionID,
|
||||
"message": map[string]interface{}{
|
||||
"role": "agent",
|
||||
"role": "agent",
|
||||
"parts": []map[string]string{{"type": "text", "text": reply}},
|
||||
},
|
||||
},
|
||||
@ -457,10 +460,10 @@ type A2AResponse struct {
|
||||
}
|
||||
|
||||
type A2AResult struct {
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "acp",
|
||||
"name_zh": "ACP 代理通信",
|
||||
"name_en": "ACP Agent Client Protocol",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
@ -47,6 +47,9 @@ 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 到本通道时有落点。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "browser",
|
||||
"name_zh": "浏览器",
|
||||
"name_en": "Browser",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.1",
|
||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
@ -45,20 +46,20 @@ type Plugin struct {
|
||||
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
||||
sharedAllocCtx context.Context
|
||||
sharedAllocCtx context.Context
|
||||
sharedAllocCancel context.CancelFunc
|
||||
sharedMu sync.Mutex
|
||||
sharedMu sync.Mutex
|
||||
}
|
||||
|
||||
type BrowserSession struct {
|
||||
id string
|
||||
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||
createdAt time.Time
|
||||
timeout time.Duration
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
id string
|
||||
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||
createdAt time.Time
|
||||
timeout time.Duration
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
currentURL string
|
||||
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
||||
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
||||
@ -159,6 +160,18 @@ func errResult(msg string) map[string]interface{} {
|
||||
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 {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
@ -191,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.SetAutoRestart(true)
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "timeout", Default: "30", Type: "int",
|
||||
@ -276,14 +292,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||
Name: tp + "start",
|
||||
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
||||
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
||||
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
|
||||
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||
},
|
||||
"required": []string{"timeout"},
|
||||
},
|
||||
}, p.handleBrowserStart)
|
||||
|
||||
@ -471,7 +488,9 @@ type searchResult struct {
|
||||
}
|
||||
|
||||
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.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")
|
||||
@ -481,38 +500,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
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
|
||||
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
for _, block := range splitBingBlocks(pageHTML) {
|
||||
if len(results) >= count {
|
||||
break
|
||||
}
|
||||
block := m[1]
|
||||
var r searchResult
|
||||
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
|
||||
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
|
||||
r.URL = hm[1]
|
||||
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
|
||||
var href, title string
|
||||
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
|
||||
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
|
||||
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||
}
|
||||
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
|
||||
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
|
||||
r.Title = stripTags(tm[1])
|
||||
href = bingRealURL(html.UnescapeString(href))
|
||||
|
||||
// 摘要:新版在 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 {
|
||||
r.Snippet = stripTags(sm[1])
|
||||
}
|
||||
if r.URL != "" && r.Title != "" {
|
||||
results = append(results, r)
|
||||
|
||||
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
|
||||
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
|
||||
continue
|
||||
}
|
||||
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
|
||||
}
|
||||
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) {
|
||||
query := readArg(args, "query", "")
|
||||
if query == "" {
|
||||
@ -881,10 +974,9 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
|
||||
}
|
||||
|
||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||
timeoutStr := readArg(args, "timeout", "10m")
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
timeout, err := parseBrowserSessionTimeout(args)
|
||||
if err != nil {
|
||||
timeout = 10 * time.Minute
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
|
||||
source := readArg(args, "source", "")
|
||||
@ -899,13 +991,19 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
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,
|
||||
"note": "已复用本来源的现有标签页(登录态全机共享)",
|
||||
"id": id,
|
||||
"status": "reused",
|
||||
"url": cur,
|
||||
"timeout": timeout.String(),
|
||||
"closes_at": closesAt.Format(time.RFC3339),
|
||||
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@ -942,7 +1040,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
"插件会注册 homeagent-browser.service 并启动。" +
|
||||
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
||||
return map[string]interface{}{
|
||||
"error": "backend not installed",
|
||||
"error": "backend not installed",
|
||||
"need_install": true,
|
||||
"guide": guide,
|
||||
}, nil
|
||||
@ -972,13 +1070,15 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
||||
session.currentURL = initURL
|
||||
}
|
||||
|
||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
|
||||
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{}{
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"mode": "shared-backend",
|
||||
"url": initURL,
|
||||
"timeout": timeout.String(),
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"mode": "shared-backend",
|
||||
"url": initURL,
|
||||
"timeout": timeout.String(),
|
||||
"closes_at": closesAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -1044,11 +1144,11 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(buf)
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"format": format,
|
||||
"size": len(buf),
|
||||
"base64": b64,
|
||||
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
||||
"status": "ok",
|
||||
"format": format,
|
||||
"size": len(buf),
|
||||
"base64": b64,
|
||||
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -1079,11 +1179,11 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
||||
html = html[:maxChars] + "\n\n[HTML truncated]"
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"title": title,
|
||||
"url": currentURL,
|
||||
"html": html,
|
||||
"length": len(html),
|
||||
"status": "ok",
|
||||
"title": title,
|
||||
"url": currentURL,
|
||||
"html": html,
|
||||
"length": len(html),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -1204,13 +1304,20 @@ func (p *Plugin) cleanupLoop() {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
p.mu.Lock()
|
||||
for id, s := range p.sessions {
|
||||
if time.Since(s.createdAt) >= s.timeout {
|
||||
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
|
||||
delete(p.sessions, id)
|
||||
s.Close()
|
||||
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
||||
s.mu.Lock()
|
||||
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)
|
||||
s.Close()
|
||||
// NoMemory:会话生命周期通知,不是记忆内容。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
@ -1310,7 +1417,7 @@ WantedBy=multi-user.target
|
||||
return map[string]interface{}{
|
||||
"status": "installed",
|
||||
"endpoint": cdpEndpoint,
|
||||
"chrome": chromePath,
|
||||
"chrome": chromePath,
|
||||
"profile": profileDir,
|
||||
"guide": guide,
|
||||
}, 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
@ -15,31 +15,31 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
RepeatNone = "none"
|
||||
RepeatDaily = "daily"
|
||||
RepeatWeekday = "weekday"
|
||||
RepeatWeekly = "weekly"
|
||||
RepeatBiweekly = "biweekly"
|
||||
RepeatMonthly = "monthly"
|
||||
RepeatYearly = "yearly"
|
||||
RepeatNone = "none"
|
||||
RepeatDaily = "daily"
|
||||
RepeatWeekday = "weekday"
|
||||
RepeatWeekly = "weekly"
|
||||
RepeatBiweekly = "biweekly"
|
||||
RepeatMonthly = "monthly"
|
||||
RepeatYearly = "yearly"
|
||||
RepeatLunarYearly = "lunar_yearly"
|
||||
)
|
||||
|
||||
type CalendarEvent struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
AllDay bool `json:"all_day,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Reminds []int `json:"reminds,omitempty"`
|
||||
RemindAt []int64 `json:"remind_at,omitempty"`
|
||||
Repeat string `json:"repeat,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Lunar bool `json:"lunar,omitempty"`
|
||||
LunarMonth int `json:"lunar_month,omitempty"`
|
||||
LunarDay int `json:"lunar_day,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
AllDay bool `json:"all_day,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Reminds []int `json:"reminds,omitempty"`
|
||||
RemindAt []int64 `json:"remind_at,omitempty"`
|
||||
Repeat string `json:"repeat,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Lunar bool `json:"lunar,omitempty"`
|
||||
LunarMonth int `json:"lunar_month,omitempty"`
|
||||
LunarDay int `json:"lunar_day,omitempty"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
@ -276,6 +276,9 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
@ -359,7 +362,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"today", sdk.ToolDef{
|
||||
Name: tp + "today", Description: "Show today's events with countdown.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleToday)
|
||||
@ -367,7 +370,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"week", sdk.ToolDef{
|
||||
Name: tp + "week", Description: "Show this week's events grouped by day.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleWeek)
|
||||
|
||||
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 v1.2.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
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))
|
||||
}
|
||||
12
example/deepsearch/plg.json
Normal file
12
example/deepsearch/plg.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "deepsearch",
|
||||
"name_zh": "联网检索",
|
||||
"name_en": "Deep Search",
|
||||
"version": "1.1.2",
|
||||
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
"sdk": "1.2.0",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.tp = p.name + "_"
|
||||
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
|
||||
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
|
||||
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
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "qq",
|
||||
"name_zh": "QQ消息",
|
||||
"name_en": "qq",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
@ -925,6 +925,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
||||
if !auth.active || auth.owner {
|
||||
return true, ""
|
||||
}
|
||||
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
|
||||
//
|
||||
// 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta
|
||||
// (group_id / user_id)决定 —— handleChannelOutput 会强制要求该字段存在,
|
||||
// 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门,
|
||||
// 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去,
|
||||
// 报「可信 QQ 会话身份不完整」。
|
||||
// 「只能访问当前会话」这类限制只对**读取类**工具(get_history / mark_read /
|
||||
// get_message)成立 —— 那才是真的不能跨会话读。
|
||||
if name == "output_send__"+p.name {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
currentPeer := auth.userID
|
||||
if auth.isGroup {
|
||||
currentPeer = auth.groupID
|
||||
@ -1435,7 +1448,12 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if p.sdk != nil {
|
||||
// NoMemory:HTTP 侧来的中断提示,不是对话内容。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{NoMemory: true})
|
||||
// Priority:QQ 消息是**低级别中断**——既不是时钟那样的实时工作,
|
||||
// 也不是紧急工作,所以声明 L1(完全可等)。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{
|
||||
NoMemory: true,
|
||||
Priority: sdk.PriorityL1,
|
||||
})
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@ -2531,9 +2549,10 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
||||
log.Printf("[qq] 文件下载完成: %s", savePath)
|
||||
if p.sdk != nil {
|
||||
// NoMemory:下载完成的状态通知,不是记忆内容。
|
||||
// Priority:同上,QQ 侧一律低级别中断(L1)。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
|
||||
}
|
||||
} else {
|
||||
errMsg = "下载失败,文件可能已过期"
|
||||
|
||||
@ -202,3 +202,36 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
|
||||
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
|
||||
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
|
||||
// (报「可信 QQ 会话身份不完整」)。
|
||||
//
|
||||
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
|
||||
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true}
|
||||
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
|
||||
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
|
||||
|
||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||
"payload": "带齐 meta 的主动发送",
|
||||
"type": "text",
|
||||
"meta": `{"user_id":2198972886}`,
|
||||
})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
|
||||
}
|
||||
|
||||
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
|
||||
if err := p.beforeToolcall(ctx2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
|
||||
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,6 +104,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.client = &http.Client{Timeout: 30 * time.Second}
|
||||
// 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用),
|
||||
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||
_ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true})
|
||||
p.fp = gofeed.NewParser()
|
||||
p.stopCh = make(chan struct{})
|
||||
p.seenGUIDs = make(map[string]bool)
|
||||
@ -158,7 +161,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"list", sdk.ToolDef{
|
||||
Name: tp + "list", Description: "List all subscribed feeds",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
@ -167,7 +170,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleCheckNow)
|
||||
@ -445,7 +448,7 @@ func (p *Plugin) loadData() {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
SeenGUIDs map[string]bool `json:"seen"`
|
||||
}
|
||||
if json.Unmarshal(b, &data) != nil {
|
||||
@ -463,7 +466,7 @@ func (p *Plugin) saveData() {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
data := struct {
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
SeenGUIDs map[string]bool `json:"seen"`
|
||||
}{
|
||||
Feeds: p.feeds,
|
||||
@ -488,8 +491,6 @@ func (p *Plugin) cleanupData() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||
func atomicWriteJSON(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
|
||||
81
example/vikunja/README.md
Normal file
81
example/vikunja/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Vikunja 插件(HomeAgent)
|
||||
|
||||
把 [Vikunja](https://vikunja.io) 待办/任务管理接入 HomeAgent:用自然语言查任务、建任务、改期、完成、看板拖动、指派、评论、时间跟踪、导入数据等。
|
||||
|
||||
## 配置项(全部可在插件配置界面修改)
|
||||
|
||||
| 键 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `url` | string | `https://vikunja.jianfgit.xyz` | 站点根地址,**不带** `/api` |
|
||||
| `token` | password(secret) | 空 | **必填**。Vikunja → 设置 → API Tokens 生成(`tk_` 开头)。令牌的权限范围决定本插件能力上限:勾全范围即为完整能力 |
|
||||
| `api_version` | select | `v2` | `v2`(推荐,标准 REST,含时间跟踪等新能力)或 `v1`(用于 v2 暂未提供的端点) |
|
||||
| `default_project_id` | string | 空 | 新建任务未指定项目时落到这里;留空则必须显式指定 |
|
||||
| `max_items` | int | `25` | 列表类工具的默认条数,控制上下文体积 |
|
||||
| `compact_output` | bool | `true` | 任务/项目/标签列表只返回关键字段;关闭则返回 Vikunja 完整对象 |
|
||||
| `timeout_seconds` | int | `20` | 单次 HTTP 超时 |
|
||||
| `verify_tls` | bool | `true` | 自签证书站点可关闭(不建议) |
|
||||
|
||||
配置在**每次工具调用前重新读取**,因此换了 token 不必重启插件。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 能力 |
|
||||
|---|---|
|
||||
| `vikunja_status` | 连接/配置自检:地址、token 对应的用户、API 版本、服务器能力、CalDAV 地址 |
|
||||
| `vikunja_tasks` | 列任务:按项目、完成状态、截止(today/this_week/overdue/no_due)、关键词、原生 filter 表达式 |
|
||||
| `vikunja_task_get` / `task_create` / `task_update` / `task_done` / `task_delete` | 任务增删改查(`task_update` 只传要改的字段) |
|
||||
| `vikunja_task_bulk` | 批量改完成状态/项目/优先级/截止/标签 |
|
||||
| `vikunja_task_assignees` / `task_labels` / `task_comments` / `task_relations` / `task_attachments` | 指派、标签、评论、关联(子任务/依赖/相关)、附件(支持上传本地文件) |
|
||||
| `vikunja_projects` / `project_views` | 项目增删改查、归档;视图与看板桶(把任务移入桶=看板拖动) |
|
||||
| `vikunja_labels` / `filters` | 标签、保存的筛选器(Saved Filter) |
|
||||
| `vikunja_teams` / `sharing` | 团队与成员;项目分享(用户/团队授权、链接分享含密码) |
|
||||
| `vikunja_notifications` / `subscriptions` / `webhooks` | 通知、订阅、Webhook 管理 |
|
||||
| `vikunja_time_entries` | 时间跟踪(**仅 v2**):补录/修改/删除、开始与停止计时器 |
|
||||
| `vikunja_migrate` | 从 TickTick/WeKan/CSV/Planka/Vikunja 文件(v2)与 Todoist/Trello/微软待办(v1)导入 |
|
||||
| `vikunja_user` / `vikunja_admin` | 当前账号(设置、登录会话、API Token)与实例管理(用户增删/提权/停用/改密、项目归属转移,需实例管理员) |
|
||||
| `vikunja_reactions` | 任务/评论的表情回应 |
|
||||
| `vikunja_api` | **通用直通**:调任意端点,未封装的能力走这里(可强制指定 v1/v2),保证能力无死角 |
|
||||
|
||||
## v1 / v2 差异(已按实例自带规范逐条核对)
|
||||
|
||||
插件默认 v2,并自动处理下列差异:
|
||||
|
||||
| 操作 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 建任务 | `PUT /projects/{id}/tasks` | `POST /projects/{id}/tasks` |
|
||||
| 改任务 | `POST /tasks/{id}`(必须整对象 → 插件自动取回-合并-提交) | `PATCH /tasks/{id}`(merge-patch,只发变更字段;被拒则回落取回-合并-PUT) |
|
||||
| 搜索参数 | `?s=` | `?q=` |
|
||||
| 加标签 | `PUT`(Label 对象) | `POST`(`{"label_id":N}`) |
|
||||
| 批量改 | `POST /tasks/bulk` | `PUT /tasks/bulk` |
|
||||
| 时间跟踪 | 不支持 | `/time-entries`(`end_time` 为 null 即计时中;停止用 `/time-entries/timer/stop`) |
|
||||
| 导入 | Todoist / Trello / 微软待办 | TickTick / WeKan / CSV / Planka / Vikunja 文件 |
|
||||
|
||||
> 官方路线:v1 仍支持但新端点只进 v2,3.0 弃用、4.0 移除。除“导入”外建议一律用 v2。
|
||||
|
||||
## 开发与构建
|
||||
|
||||
```bash
|
||||
cd third_party/homeagent-sdk/example/vikunja
|
||||
|
||||
go test -count=1 -race ./... # 16 项测试(httptest 打桩,不需要真 token)
|
||||
hmapdev build # 产出 dist/vikunja_bundle.hmap
|
||||
```
|
||||
|
||||
`go.mod` 里的 `replace` 把 SDK 指向仓库内的 `third_party/homeagent-sdk`,因此无需联网拉私有模块。
|
||||
|
||||
### 部署到运行实例
|
||||
|
||||
`.hmap` 包内是 `plugin.json` + `plugin.bin.<os>.<arch>`,安装时按运行平台重命名入口文件:
|
||||
|
||||
```bash
|
||||
unzip -o dist/vikunja_bundle.hmap -d /home/newqqagent/plugins/vikunja
|
||||
cd /home/newqqagent/plugins/vikunja && mv plugin.bin.linux.amd64 plugin.bin
|
||||
# 然后重载插件(或重启 homeagent.service)
|
||||
```
|
||||
|
||||
## 已知边界
|
||||
|
||||
- **附件下载**未单独封装:`task_attachments` 支持列出/上传/删除,下载请用 `vikunja_api` 访问附件 URL。
|
||||
- **链接分享的字段**(`right`/`password`)按 Vikunja 版本语义透传;如遇 4xx,可直接用 `raw` 参数传完整 JSON。
|
||||
- **批量改标签**的 `fields` 结构以 `BulkTask` 为准,未在真实实例上验证过(缺少可用 token),如有偏差请用 `vikunja_api` 直通。
|
||||
- CalDAV 是客户端协议,插件只提供地址(`vikunja_status` 里的 `caldav_url`),不做 CalDAV 同步。
|
||||
15
example/vikunja/go.mod
Normal file
15
example/vikunja/go.mod
Normal file
@ -0,0 +1,15 @@
|
||||
module vikunja-plugin
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
||||
|
||||
// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
12
example/vikunja/plg.json
Normal file
12
example/vikunja/plg.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "vikunja",
|
||||
"name_zh": "Vikunja 待办",
|
||||
"name_en": "Vikunja",
|
||||
"version": "1.0.1",
|
||||
"description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
"sdk": "1.2.0",
|
||||
"tags": ["vikunja", "todo", "task", "gtd", "productivity"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
2617
example/vikunja/plugin.go
Normal file
2617
example/vikunja/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
523
example/vikunja/plugin_test.go
Normal file
523
example/vikunja/plugin_test.go
Normal file
@ -0,0 +1,523 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestPlugin 构造一个不依赖 sdk 的插件实例,指向 httptest 服务。
|
||||
// ensure() 在 sdk==nil 时会保留已设置的字段,因此可以这样直接测处理器。
|
||||
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
p := &Plugin{
|
||||
name: "vikunja",
|
||||
baseURL: srv.URL,
|
||||
token: "tk_test",
|
||||
apiVer: "v2",
|
||||
maxItems: 5,
|
||||
compact: true,
|
||||
http: srv.Client(),
|
||||
}
|
||||
return p, srv
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// 1) 列表:v2 用 q= 搜索,且 filter 会带上默认 done 条件
|
||||
func TestTasksListV2(t *testing.T) {
|
||||
var gotQuery string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
if r.Header.Get("Authorization") != "Bearer tk_test" {
|
||||
t.Errorf("缺少 Bearer 头: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"写周报","done":false,"project_id":3,"due_date":"2026-09-13T10:00:00Z","labels":[{"title":"工作"}],"assignees":[{"username":"jianf"}]}]`))
|
||||
})
|
||||
res, err := p.handleTasksList(map[string]interface{}{"search": "周报", "limit": float64(5)})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "q=%E5%91%A8%E6%8A%A5") {
|
||||
t.Errorf("v2 应使用 q= 搜索,实际 query=%s", gotQuery)
|
||||
}
|
||||
if strings.Contains(gotQuery, "s=") {
|
||||
t.Errorf("v2 不应使用 s=,实际 query=%s", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "per_page=5") {
|
||||
t.Errorf("per_page 未生效: %s", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "filter=done+%3D+false") && !strings.Contains(gotQuery, "filter=done%20%3D%20false") {
|
||||
t.Errorf("默认应过滤未完成,实际 filter 片段: %s", gotQuery)
|
||||
}
|
||||
m, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("结果应为 map,实际 %T", res)
|
||||
}
|
||||
if m["count"].(int) != 1 {
|
||||
t.Errorf("count 应为 1,实际 %v", m["count"])
|
||||
}
|
||||
tasks := m["tasks"].([]interface{})
|
||||
tk := tasks[0].(map[string]interface{})
|
||||
if _, ok := tk["labels"].([]string); !ok {
|
||||
t.Errorf("标签应被投影成名称数组,实际 %T", tk["labels"])
|
||||
}
|
||||
if _, ok := tk["description"]; ok {
|
||||
t.Errorf("精简输出不应出现 description")
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 列表:v1 用 s= 搜索
|
||||
func TestTasksListV1SearchParam(t *testing.T) {
|
||||
var gotQuery string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
p.apiVer = "v1"
|
||||
if _, err := p.handleTasksList(map[string]interface{}{"search": "abc"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "s=abc") {
|
||||
t.Errorf("v1 应使用 s= 搜索,实际 %s", gotQuery)
|
||||
}
|
||||
if strings.Contains(gotQuery, "q=") {
|
||||
t.Errorf("v1 不应出现 q=,实际 %s", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 建任务:v1=PUT、v2=POST(同路径,方法不同)
|
||||
func TestTaskCreateMethodByVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ver string
|
||||
method string
|
||||
}{
|
||||
{"v1", http.MethodPut},
|
||||
{"v2", http.MethodPost},
|
||||
} {
|
||||
var gotMethod, gotPath string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod, gotPath = r.Method, r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
|
||||
})
|
||||
p.apiVer = tc.ver
|
||||
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": "3", "title": "买菜"}); err != nil {
|
||||
t.Fatalf("[%s] err: %v", tc.ver, err)
|
||||
}
|
||||
if gotMethod != tc.method {
|
||||
t.Errorf("[%s] 期望 %s,实际 %s", tc.ver, tc.method, gotMethod)
|
||||
}
|
||||
if gotPath != "/api/"+tc.ver+"/projects/3/tasks" {
|
||||
t.Errorf("[%s] 路径错误: %s", tc.ver, gotPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 改任务(v2):走 merge-patch,只发变更字段
|
||||
func TestTaskUpdateV2MergePatch(t *testing.T) {
|
||||
var method, ctype string
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
method = r.Method
|
||||
ctype = r.Header.Get("Content-Type")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":7,"done":true}`))
|
||||
})
|
||||
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "done": true}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if method != http.MethodPatch {
|
||||
t.Errorf("v2 应用 PATCH,实际 %s", method)
|
||||
}
|
||||
if !strings.Contains(ctype, "merge-patch") {
|
||||
t.Errorf("应使用 merge-patch 内容类型,实际 %s", ctype)
|
||||
}
|
||||
if len(body) != 1 || body["done"] != true {
|
||||
t.Errorf("只应发送变更字段,实际 %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 改任务(v2)回退:merge-patch 被拒 → 取回-合并-PUT
|
||||
func TestTaskUpdateV2FallbackToMergePut(t *testing.T) {
|
||||
var calls []string
|
||||
var putBody map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
switch {
|
||||
case r.Method == http.MethodPatch:
|
||||
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_, _ = w.Write([]byte(`{"code":9,"message":"unsupported media type"}`))
|
||||
case r.Method == http.MethodGet:
|
||||
_, _ = w.Write([]byte(`{"id":7,"title":"旧标题","done":false,"priority":1}`))
|
||||
case r.Method == http.MethodPut:
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &putBody)
|
||||
_, _ = w.Write([]byte(`{"id":7,"title":"新标题","done":false,"priority":1}`))
|
||||
default:
|
||||
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "title": "新标题"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"PATCH /api/v2/tasks/7", "GET /api/v2/tasks/7", "PUT /api/v2/tasks/7"}
|
||||
if len(calls) != len(want) {
|
||||
t.Fatalf("调用序列不符: %v", calls)
|
||||
}
|
||||
for i := range want {
|
||||
if calls[i] != want[i] {
|
||||
t.Errorf("第 %d 步期望 %s,实际 %s", i+1, want[i], calls[i])
|
||||
}
|
||||
}
|
||||
if putBody["title"] != "新标题" {
|
||||
t.Errorf("合并后的 body 应含新标题,实际 %v", putBody)
|
||||
}
|
||||
if putBody["priority"] != float64(1) {
|
||||
t.Errorf("合并必须保留原有字段(priority),实际 %v", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 改任务(v1):没有 merge-patch,必须取回-合并-POST
|
||||
func TestTaskUpdateV1FetchMergePost(t *testing.T) {
|
||||
var calls []string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(`{"id":9,"title":"旧","priority":2}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":9,"title":"新","priority":2}`))
|
||||
})
|
||||
p.apiVer = "v1"
|
||||
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "9", "title": "新"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/tasks/9", "POST /api/v1/tasks/9"}
|
||||
if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] {
|
||||
t.Fatalf("v1 应为 GET→POST,实际 %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 错误映射:401 提示检查 token
|
||||
func TestErrorHint401(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":11,"message":"invalid token"}`))
|
||||
})
|
||||
_, err := p.handleTasksList(map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("应返回错误")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "401") || !strings.Contains(msg, "code=11") {
|
||||
t.Errorf("错误信息应含状态码与 Vikunja code,实际 %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "token") {
|
||||
t.Errorf("401 应给出 token 提示,实际 %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 8) 未配置 token 时应给出可操作提示,而不是发出无凭据请求
|
||||
func TestMissingToken(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("未配置 token 时不应发请求")
|
||||
})
|
||||
p.token = ""
|
||||
_, err := p.handleTasksList(map[string]interface{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), "vikunja.token") {
|
||||
t.Fatalf("应提示配置项名,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 9) 导入:Todoist 必须走 v1(即使插件默认是 v2)
|
||||
func TestMigrateUsesV1ForTodoist(t *testing.T) {
|
||||
var path string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "todoist", "code": "abc"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if path != "/api/v1/migration/todoist/migrate" {
|
||||
t.Errorf("Todoist 导入必须走 v1,实际 %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// 10) 导入:WeKan 走 v2
|
||||
func TestMigrateUsesV2ForWekan(t *testing.T) {
|
||||
var path, method string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
path, method = r.URL.Path, r.Method
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "wekan"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if path != "/api/v2/migration/wekan/migrate" || method != http.MethodPost {
|
||||
t.Errorf("WeKan 应走 v2 POST,实际 %s %s", method, path)
|
||||
}
|
||||
}
|
||||
|
||||
// 11) 时间跟踪:秒数换算成 end_time;计时开始则不带 end_time
|
||||
func TestTimeEntrySecondsBecomesEndTime(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
})
|
||||
start := "2026-09-12T10:00:00+08:00"
|
||||
if _, err := p.handleTimeEntries(map[string]interface{}{
|
||||
"action": "create", "task_id": "5", "seconds": float64(600),
|
||||
"start_time": start,
|
||||
}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
// 判据不写死字符串:按时区无关的方式比较两个时间点
|
||||
sStart, err := time.Parse(time.RFC3339, start)
|
||||
if err != nil {
|
||||
t.Fatalf("case 自身时间写错: %v", err)
|
||||
}
|
||||
gotEnd, ok := body["end_time"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("应有 end_time,实际 %v", body["end_time"])
|
||||
}
|
||||
tEnd, err := time.Parse(time.RFC3339, gotEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("end_time 不是 RFC3339: %q", gotEnd)
|
||||
}
|
||||
if diff := tEnd.Sub(sStart); diff != 10*time.Minute {
|
||||
t.Errorf("end_time 应由 start_time+600s 推出,实际差值 %v", diff)
|
||||
}
|
||||
if _, ok := body["seconds"]; ok {
|
||||
t.Errorf("TimeEntry 没有 seconds 字段,不应发送:%v", body)
|
||||
}
|
||||
|
||||
body = nil
|
||||
if _, err := p.handleTimeEntries(map[string]interface{}{"action": "timer_start", "task_id": "5"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
v, present := body["end_time"]
|
||||
if !present || v != nil {
|
||||
t.Errorf("计时开始应显式 end_time=null(live timer),实际 %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 12) 时间跟踪在 v1 下应给出明确不可用提示
|
||||
func TestTimeEntryUnavailableOnV1(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
p.apiVer = "v1"
|
||||
_, err := p.handleTimeEntries(map[string]interface{}{"action": "list"})
|
||||
if err == nil || !strings.Contains(err.Error(), "v2") {
|
||||
t.Fatalf("v1 下应提示改用 v2,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 13) 标签:v1 收 Label 对象、v2 收 label_id
|
||||
func TestLabelBodyByVersion(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if body["label_id"] != float64(5) {
|
||||
t.Errorf("v2 应发送 label_id,实际 %v", body)
|
||||
}
|
||||
|
||||
body = nil
|
||||
p.apiVer = "v1"
|
||||
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if body["id"] != float64(5) {
|
||||
t.Errorf("v1 应发送 Label 对象(id),实际 %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 14) 时间字符串容忍:today / +3d / ISO
|
||||
func TestNormalizeTime(t *testing.T) {
|
||||
for _, in := range []string{"today", "tomorrow", "+3d", "2026-09-12 18:00", "2026-09-12T18:00:00+08:00"} {
|
||||
got := normalizeTime(in)
|
||||
s, ok := got.(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s: 期望字符串,实际 %T", in, got)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, s); err != nil {
|
||||
t.Errorf("%s → %s 不是 RFC3339: %v", in, s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 15) 通用直通:可指定 api_version,method 大小写不敏感
|
||||
func TestRawAPI(t *testing.T) {
|
||||
var method, path string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
method, path = r.Method, r.URL.Path
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
if _, err := p.handleRawAPI(map[string]interface{}{"method": "get", "path": "projects", "api_version": "v1"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if method != http.MethodGet || path != "/api/v1/projects" {
|
||||
t.Errorf("直通参数未生效: %s %s", method, path)
|
||||
}
|
||||
}
|
||||
|
||||
// 16) 精简输出可关闭(关闭时返回原样)
|
||||
func TestCompactToggle(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"t","description":"很长的描述","done":false}]`))
|
||||
})
|
||||
p.compact = false
|
||||
res, err := p.handleTasksList(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
arr, ok := res.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("关闭精简后应原样返回数组,实际 %T", res)
|
||||
}
|
||||
if _, ok := arr[0].(map[string]interface{})["description"]; !ok {
|
||||
t.Errorf("关闭精简后应保留 description")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 回归:JSON body 里的 ID 必须是数字(线上实测的 422 缺口)────────────
|
||||
//
|
||||
// vikunja v2.6.0 实测(2026-09-12):
|
||||
// {"project_id":"1"} → 422 expected integer at body.project_id
|
||||
// {"user_id":"1"} → 422 expected integer at body.user_id
|
||||
// {"username":"jianf"} → 422 unexpected property at body.username
|
||||
// 旧实现把 argID() 的字符串直接塞进 body,assignee 还额外带 username,
|
||||
// 于是「建任务」「指派」在 v2 下必定失败 —— 只有真调用才暴露,单测没盖到。
|
||||
|
||||
func TestTaskCreateSendsNumericProjectID(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
var raw []byte
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ = io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
|
||||
})
|
||||
// project_id 传 float64 —— 这正是 SDK 从 JSON 解出来的真实类型
|
||||
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": float64(3), "title": "买菜"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if _, ok := body["project_id"].(float64); !ok {
|
||||
t.Errorf("project_id 必须是 JSON 数字,实际 %T=%v", body["project_id"], body["project_id"])
|
||||
}
|
||||
if strings.Contains(string(raw), `"project_id":"`) {
|
||||
t.Errorf("出现字符串型 project_id(v2 会 422 expected integer): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddResolvesUsernameToNumericUserID(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
var raw []byte
|
||||
var calls []string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users":
|
||||
if r.URL.Query().Get("q") != "alice" {
|
||||
t.Errorf("v2 用户搜索应用 q=,实际 query=%q", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"},{"id":9,"username":"alice2"}]`))
|
||||
case "/api/v2/tasks/1/assignees":
|
||||
raw, _ = io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user_id":7}`))
|
||||
default:
|
||||
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("应先查用户再指派,实际调用: %v", calls)
|
||||
}
|
||||
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
|
||||
t.Errorf("user_id 必须是数字 7,实际 %T=%v", body["user_id"], body["user_id"])
|
||||
}
|
||||
if _, ok := body["username"]; ok {
|
||||
t.Errorf("v2 不接受 username 字段(422 unexpected property): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddNumericUserSkipsLookup(t *testing.T) {
|
||||
var calls []string
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
if r.URL.Path == "/api/v2/users" {
|
||||
t.Errorf("传数字 ID 时不该再查用户表")
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user_id":7}`))
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "7"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Errorf("应只有一次请求,实际: %v", calls)
|
||||
}
|
||||
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
|
||||
t.Errorf("user_id 应为数字 7,实际 %T=%v", body["user_id"], body["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeRemoveUsesResolvedNumericPath(t *testing.T) {
|
||||
var gotPath string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users":
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"}]`))
|
||||
default:
|
||||
gotPath = r.Method + " " + r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "remove", "user": "alice"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if gotPath != "DELETE /api/v2/tasks/1/assignees/7" {
|
||||
t.Errorf("移除应用解析出的数字 ID,实际 %q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddUnknownUserGivesReadableError(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"bob"}]`))
|
||||
})
|
||||
_, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"})
|
||||
if err == nil {
|
||||
t.Fatal("找不到用户时必须报错,而不是发出一个注定 422 的请求")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "找不到用户") || !strings.Contains(err.Error(), "bob") {
|
||||
t.Errorf("错误信息应说明找不到并给出相近候选: %v", err)
|
||||
}
|
||||
}
|
||||
@ -50,7 +50,7 @@ var (
|
||||
// 1.2.0 就归发布线所有,main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag,
|
||||
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。
|
||||
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。)
|
||||
Version = "1.2.0"
|
||||
Version = "1.3.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
104
sdk/plugin.go
104
sdk/plugin.go
@ -78,8 +78,30 @@ type InjectOptions struct {
|
||||
NoMemory bool
|
||||
ContextPolicy string
|
||||
CleanerName string
|
||||
|
||||
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
|
||||
//
|
||||
// 取值 PriorityL1..PriorityL4;空等同 L1(默认级)。
|
||||
// L4 只有**内核级插件**能用(见 PriorityL4 注释);外部插件的 L4 会被夹到 L3。
|
||||
//
|
||||
// 排队注入(InjectText*/InjectInputSync)没有级别:它们本就是“不需及时处理”
|
||||
// 的那一类,可被任何中断打断。
|
||||
Priority string
|
||||
}
|
||||
|
||||
// 中断优先级取值。
|
||||
//
|
||||
// L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||
// 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||
// 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||
const (
|
||||
PriorityL1 = "L1"
|
||||
PriorityL2 = "L2"
|
||||
PriorityL3 = "L3"
|
||||
// PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||
PriorityL4 = "L4"
|
||||
)
|
||||
|
||||
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||
@ -262,6 +284,13 @@ type InputChannelRegistrar func(name string, def ChannelDef) error
|
||||
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||
|
||||
// OutputChannelUnregistrar 注销一个输出通道。
|
||||
//
|
||||
// 为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
|
||||
// 动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
|
||||
// 必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
|
||||
type OutputChannelUnregistrar func(name string) error
|
||||
|
||||
// Output capability flags
|
||||
const (
|
||||
CapText = 1
|
||||
@ -274,22 +303,23 @@ const (
|
||||
// PluginSDK is the main API surface provided to plugins at runtime.
|
||||
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
|
||||
type PluginSDK struct {
|
||||
name string
|
||||
regTool ToolRegistrar
|
||||
regStage StageRegistrar
|
||||
regAPI APIRegistrar
|
||||
regOutput OutputChannelRegistrar
|
||||
regInput InputChannelRegistrar
|
||||
io IOInjector
|
||||
mem MemoryAPI
|
||||
textMem TextMemoryAPI
|
||||
docMem DocMemoryAPI
|
||||
know KnowledgeAPI
|
||||
llm LLMAPI
|
||||
sett SettingsAPI
|
||||
social SocialAPI
|
||||
events EventSubscriber
|
||||
plgMgr PluginMgrAPI
|
||||
name string
|
||||
regTool ToolRegistrar
|
||||
regStage StageRegistrar
|
||||
regAPI APIRegistrar
|
||||
regOutput OutputChannelRegistrar
|
||||
regOutputUnreg OutputChannelUnregistrar
|
||||
regInput InputChannelRegistrar
|
||||
io IOInjector
|
||||
mem MemoryAPI
|
||||
textMem TextMemoryAPI
|
||||
docMem DocMemoryAPI
|
||||
know KnowledgeAPI
|
||||
llm LLMAPI
|
||||
sett SettingsAPI
|
||||
social SocialAPI
|
||||
events EventSubscriber
|
||||
plgMgr PluginMgrAPI
|
||||
|
||||
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||
//
|
||||
@ -445,7 +475,20 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||
}
|
||||
|
||||
// RegisterOutputChannel registers an output channel that the output_send tool can route to.
|
||||
// name: channel name (e.g. "qq", "webui")
|
||||
//
|
||||
// 与 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)
|
||||
@ -460,7 +503,27 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, de
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
|
||||
func (s *PluginSDK) UnregisterOutputChannel(name string) error {
|
||||
s.apiMu.RLock()
|
||||
reg := s.regOutputUnreg
|
||||
s.apiMu.RUnlock()
|
||||
if reg != nil {
|
||||
return reg(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
|
||||
@ -482,6 +545,13 @@ func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) {
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
// SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar) {
|
||||
s.apiMu.Lock()
|
||||
s.regOutputUnreg = r
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) {
|
||||
s.apiMu.Lock()
|
||||
|
||||
@ -95,6 +95,18 @@ func cmdBuild(args []string) {
|
||||
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath))
|
||||
}
|
||||
|
||||
// SDK 能力前置校验:proc 桥的模板(z_proc_gen.go)会透传 InjectOptions.Priority,
|
||||
// 而旧版 SDK 没有这个字段。不校验的话,用户看到的是 z_proc_gen.go 里两条
|
||||
// "opts.Priority undefined" 编译错误——错误信息指向生成物,完全看不出是 SDK 版本问题。
|
||||
if sdkPath != "" && !sdkHasInjectPriority(sdkPath) {
|
||||
fmt.Printf("error: 当前 SDK(%s)缺少 sdk.InjectOptions.Priority\n", plg.ResolvedSDK)
|
||||
fmt.Printf(" 子进程模式(proc 桥)的模板需要它来透传注入优先级 L1-L4。\n")
|
||||
fmt.Printf(" 解决办法(二选一):\n")
|
||||
fmt.Printf(" 1) 升级 SDK:hmapdev sdk install <含该能力的版本> && hmapdev sdk use <版本>\n")
|
||||
fmt.Printf(" 2) 用本地 SDK 源码:hmapdev sdk install --from /path/to/homeagent-sdk\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Ensure go.mod exists with correct SDK path
|
||||
sdkModule := ensureGoMod(plg, sdkPath)
|
||||
|
||||
@ -869,3 +881,22 @@ func linkThirdpart(plg *PlgConfig, target string) func() {
|
||||
os.Remove(importFile)
|
||||
}
|
||||
}
|
||||
|
||||
// sdkHasInjectPriority 报告该 SDK 源码是否已具备 InjectOptions.Priority
|
||||
// (proc 桥透传注入优先级所必需的能力;SDK 开发期与已发布版本可能不一致)。
|
||||
func sdkHasInjectPriority(sdkPath string) bool {
|
||||
data, err := os.ReadFile(filepath.Join(sdkPath, "sdk", "plugin.go"))
|
||||
if err != nil {
|
||||
return true // 读不到就不拦(不在校验范围内)
|
||||
}
|
||||
src := string(data)
|
||||
i := strings.Index(src, "type InjectOptions struct")
|
||||
if i < 0 {
|
||||
return true
|
||||
}
|
||||
seg := src[i:]
|
||||
if j := strings.Index(seg, "\n}"); j > 0 {
|
||||
seg = seg[:j]
|
||||
}
|
||||
return strings.Contains(seg, "Priority")
|
||||
}
|
||||
|
||||
@ -58,6 +58,27 @@ func cmdSDK(args []string) {
|
||||
sdkHelp()
|
||||
return
|
||||
}
|
||||
// install --from <本地目录> [version]:用本地 SDK 源码装一个版本并激活。
|
||||
if args[0] == "install" {
|
||||
from := ""
|
||||
rest := []string{}
|
||||
for i := 1; i < len(args); i++ {
|
||||
if args[i] == "--from" && i+1 < len(args) {
|
||||
from = args[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
if from != "" {
|
||||
version := ""
|
||||
if len(rest) > 0 && rest[0] != "latest" {
|
||||
version = rest[0]
|
||||
}
|
||||
cmdSDKInstallFromDir(from, version)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
cmdSDKList()
|
||||
@ -100,7 +121,8 @@ Commands:
|
||||
Examples:
|
||||
hmapdev sdk install v0.7.1
|
||||
hmapdev sdk install latest
|
||||
hmapdev sdk use v0.7.1
|
||||
hmapdev sdk install v0.7.1
|
||||
hmapdev sdk install --from /path/to/homeagent-sdk # 用本地源码(SDK 开发时用)sdk use v0.7.1
|
||||
`)
|
||||
}
|
||||
|
||||
@ -144,6 +166,51 @@ func cmdSDKList() {
|
||||
}
|
||||
}
|
||||
|
||||
// cmdSDKInstallFromDir 从**本地 SDK 源码目录**安装一个版本。
|
||||
//
|
||||
// 为什么需要它:`install` 只能从 Release 归档下载,而 SDK 开发时的新能力
|
||||
// (例如 `InjectOptions.Priority` 这类 proc 桥要透传的字段)往往还没发版 ——
|
||||
// 此时生成出来的插件工程会因为"引用的 SDK 还没有该字段"直接编译失败。
|
||||
// 有 --from 才能"用本地源码当这个版本的 SDK",边改 SDK 边验证模板工程。
|
||||
func cmdSDKInstallFromDir(src, version string) {
|
||||
store := sdkStore()
|
||||
if err := os.MkdirAll(store, 0755); err != nil {
|
||||
fmt.Printf("error: create SDK store %s: %v\n", store, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if version == "" {
|
||||
version = readMetaVersion(src)
|
||||
}
|
||||
if version == "" {
|
||||
fmt.Printf("error: cannot determine version from %s/meta/meta.go\n", src)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
version = "v" + version
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, "go.mod")); err != nil {
|
||||
fmt.Printf("error: %s 看起来不是 SDK 源码目录(缺 go.mod)\n", src)
|
||||
os.Exit(1)
|
||||
}
|
||||
dest := sdkVersionDir(version)
|
||||
_ = os.RemoveAll(dest)
|
||||
if err := copyDir(src, dest); err != nil {
|
||||
fmt.Printf("error: copy %s -> %s: %v\n", src, dest, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 源码目录里的开发产物不该带进 store。
|
||||
for _, junk := range []string{".git", "dist", "build"} {
|
||||
_ = os.RemoveAll(filepath.Join(dest, junk))
|
||||
}
|
||||
fmt.Printf("Installed SDK %s from %s\n", version, src)
|
||||
fmt.Printf(" %s\n", dest)
|
||||
if err := os.WriteFile(filepath.Join(store, "current"), []byte(version), 0644); err != nil {
|
||||
fmt.Printf("error: activate %s: %v\n", version, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Activated SDK %s\n", version)
|
||||
}
|
||||
|
||||
// cmdSDKInstall downloads and installs an SDK version from Release archive.
|
||||
func cmdSDKInstall(version string) {
|
||||
store := sdkStore()
|
||||
@ -520,5 +587,3 @@ func readMetaVersion(sdkRoot string) string {
|
||||
}
|
||||
return "0.0.0"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -49,6 +49,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
DisplayName: "示例配置", Description: "An example configuration key",
|
||||
Category: "{{.Plg.Name}}",
|
||||
})
|
||||
// ---- 通道(channel):两个方向是分开的两件事 ----
|
||||
//
|
||||
// 入站 inputch ——「谁会往这个通道注入输入」。
|
||||
// 凡是用 s.InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名,
|
||||
// 都要在这里登记:inputch 是内核最基本的**输入路由单位**,只有登记过的通道
|
||||
// 才能被「划给驻留子(resident sub-agent)」;没登记就划分会失败(inputch 未注册)。
|
||||
// 只登记出站通道时内核会兜底登记同名 inputch **并打告警**(兼容老插件)。
|
||||
chName := p.name
|
||||
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{NoMemory: true})
|
||||
// 出站 output ——「output_send__<name> 的回复发给谁」。
|
||||
// handler 收到 map:payload(string) / type(string) / meta(string|optional)。
|
||||
_ = s.RegisterOutputChannel(chName, sdk.CapText, "示例通道(回复由此返回)",
|
||||
sdk.ChannelDef{NoMemory: true}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
})
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||
Name: tp + "hello",
|
||||
@ -148,6 +164,15 @@ const tmplMainLua = `-- {{.Plg.Name}} plugin
|
||||
local plugin = { name = "{{.Plg.Name}}" }
|
||||
function plugin.start(sdk)
|
||||
sdk.log("info", "{{.Plg.Name}} starting...")
|
||||
|
||||
-- 通道:入站与出站分开登记。
|
||||
-- 入站 inputch:凡是用 sdk.inject_text/sdk.inject_interrupt(source, "<name>", ...) 注入的通道名
|
||||
-- 都要登记;只有登记过的通道才能被「划给驻留子」(没登记会报 inputch 未注册)。
|
||||
sdk.register_input_channel("{{.Plg.Name}}", { no_memory = true })
|
||||
-- 出站 output:output_send__<name> 的回复由 handler 处理
|
||||
sdk.register_output_channel("{{.Plg.Name}}", 1, "示例通道(回复由此返回)", { no_memory = true },
|
||||
function(args) return { status = "ok" } end)
|
||||
|
||||
sdk.register_tool("{{.Plg.Name}}_hello", {
|
||||
description = "A hello world tool",
|
||||
parameters = { type = "object", properties = {} }
|
||||
@ -515,6 +540,21 @@ const tmplReadme = `# {{.Plg.Name}}
|
||||
hmapdev build
|
||||
` + "```" + `
|
||||
|
||||
## Channels
|
||||
|
||||
入站与出站是分开登记的两件事:
|
||||
|
||||
| 方向 | API | 用途 |
|
||||
|---|---|---|
|
||||
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
|
||||
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁;handler 收到 {payload,type,meta} |
|
||||
|
||||
def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆;
|
||||
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
|
||||
|
||||
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
|
||||
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
|
||||
@ -287,6 +287,10 @@ func applyInjectOpts(args map[string]interface{}, opts sdk.InjectOptions) {
|
||||
if opts.CleanerName != "" {
|
||||
args["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
// priority 只对中断注入有意义(排队注入没有级别)。
|
||||
if opts.Priority != "" {
|
||||
args["priority"] = opts.Priority
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 全局状态 ----
|
||||
|
||||
Reference in New Issue
Block a user