mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 17:08:01 +00:00
docs: 修正全部文档使其与源码实现一致
- Plugin.Start(sdk *PluginSDK) 接口签名改为指针 - 方法表重写: 移除 CallLLM/QueryKnowledge/SetMemory 等不存在方法 - IOInjector 参数顺序修正为 (source, channel, text) - 删除虚构 SDKConfig, 替换为实际 New() 构造函数签名 - .hmap 内容描述一致化 (plugin.so + plugin.dll + main.lua) - 添加 meta/ 包元数据文件
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Build artifacts
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
*.hmap
|
||||||
|
plugin.json
|
||||||
|
|
||||||
|
# Build output directories
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
testdist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.logz_bridge_gen.go\nz_entry.c\nbuild/\ndist/
|
||||||
|
z_bridge_gen.go
|
||||||
|
z_entry.c
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
192
README.md
Normal file
192
README.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# HomeAgent SDK
|
||||||
|
|
||||||
|
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||||
|
|
||||||
|
## SDK API 接口
|
||||||
|
|
||||||
|
### Plugin 接口
|
||||||
|
|
||||||
|
插件需实现 `Plugin` 接口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Plugin interface {
|
||||||
|
Name() string
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
Stop() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PluginSDK 方法
|
||||||
|
|
||||||
|
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
||||||
|
|
||||||
|
| 分类 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||||
|
| 输出通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输出通道,caps 为能力位掩码 |
|
||||||
|
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||||
|
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||||
|
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||||
|
| 文本记忆 | `TextMemory()` | 访问文本记忆 API(时序事件) |
|
||||||
|
| 文档记忆 | `DocMemory()` | 访问文档记忆 API(向量存储) |
|
||||||
|
| 社交图谱 | `Social()` | 访问社交图谱 API(外部插件只读) |
|
||||||
|
| 知识库 | `Knowledge()` | 访问知识库 API |
|
||||||
|
| LLM | `LLM()` | 访问 LLM 提供商管理 API |
|
||||||
|
| 设置 | `Settings()` | 访问设置 API |
|
||||||
|
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
||||||
|
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
||||||
|
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
||||||
|
|
||||||
|
### 阶段钩子
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 全局监听所有插件的阶段事件
|
||||||
|
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||||||
|
|
||||||
|
// 仅监听自己注册的工具的 before_toolcall / after_toolcall
|
||||||
|
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输出通道
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
能力标志位:
|
||||||
|
|
||||||
|
| 标志 | 值 | 说明 |
|
||||||
|
|------|----|------|
|
||||||
|
| `CapText` | 1 | 纯文本输出 |
|
||||||
|
| `CapFile` | 2 | 文件输出 |
|
||||||
|
| `CapImage` | 4 | 图片输出 |
|
||||||
|
| `CapAudio` | 8 | 音频输出 |
|
||||||
|
| `CapStructured` | 16 | 结构化数据输出 |
|
||||||
|
|
||||||
|
### IOInjector 通道路由
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `InjectText(source, channel, text)` | 注入文本,记入内存,路由到指定通道 |
|
||||||
|
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
||||||
|
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
||||||
|
|
||||||
|
`source` 标识来源,`channel` 指定目标输出通道。
|
||||||
|
|
||||||
|
### Triple 扩展字段
|
||||||
|
|
||||||
|
Triple 数据结构新增字段:
|
||||||
|
|
||||||
|
- `Confidence` — 置信度(0.0~1.0)
|
||||||
|
- `SubjectType` — 主体类型
|
||||||
|
- `ObjectType` — 客体类型
|
||||||
|
|
||||||
|
### New 构造函数
|
||||||
|
|
||||||
|
`New()` 由内核在加载插件时调用,插件开发者无需手动构造 PluginSDK:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||||||
|
```
|
||||||
|
|
||||||
|
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。
|
||||||
|
|
||||||
|
## plugindev 工具链
|
||||||
|
|
||||||
|
`plugindev` 提供插件开发全流程支持:
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) |
|
||||||
|
| `plugindev build` | 构建插件,输出 .hmap 包 |
|
||||||
|
| `plugindev clean` | 清理构建产物 |
|
||||||
|
| `plugindev debug` | 本地调试模式运行插件 |
|
||||||
|
|
||||||
|
支持 **Go** 和 **Lua** 两种插件语言。
|
||||||
|
|
||||||
|
### plg.json 清单格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lang": "go",
|
||||||
|
"entry": "main.go",
|
||||||
|
"description": "插件描述",
|
||||||
|
"channels": ["my-channel"],
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### .hmap 包格式
|
||||||
|
|
||||||
|
`.hmap` 为 ZIP 归档,包含:
|
||||||
|
|
||||||
|
- `plugin.json` — 插件元数据
|
||||||
|
- `plugin.so` — Go 编译产物(Linux)
|
||||||
|
- `plugin.dll` — Go 编译产物(Windows)
|
||||||
|
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||||
|
|
||||||
|
## 插件生命周期
|
||||||
|
|
||||||
|
### 启动与停止
|
||||||
|
|
||||||
|
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
||||||
|
- `Stop() error` — 插件停止,释放资源
|
||||||
|
|
||||||
|
### 自动重启
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.SetAutoRestart(true)
|
||||||
|
// 查询状态
|
||||||
|
enabled := sdk.AutoRestart()
|
||||||
|
```
|
||||||
|
|
||||||
|
插件崩溃时平台自动拉起,保障服务可用性。
|
||||||
|
|
||||||
|
## 受限 SDK vs 完整 SDK
|
||||||
|
|
||||||
|
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
||||||
|
|
||||||
|
| 受限 API | 允许操作 |
|
||||||
|
|----------|----------|
|
||||||
|
| `SocialAPI` | 只读:`GetPerson`、`GetTrait`、`GetRelations`、`GetNetwork`、`ListPersons` |
|
||||||
|
| `EventSubscriber` | 仅订阅:`Subscribe`(无 `Publish`) |
|
||||||
|
|
||||||
|
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
||||||
|
|
||||||
|
## 示例插件
|
||||||
|
|
||||||
|
| 插件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| a2a | Agent-to-Agent 协议通信 |
|
||||||
|
| bili | Bilibili 数据获取 |
|
||||||
|
| editdoc | 文档编辑 |
|
||||||
|
| files | 文件管理 |
|
||||||
|
| memo | 备忘录/记忆 |
|
||||||
|
| ocr | 光学字符识别 |
|
||||||
|
| qq | QQ 消息集成 |
|
||||||
|
| sanitizer | 内容清洗/安全过滤 |
|
||||||
|
| web | 网页浏览与交互 |
|
||||||
|
| webfetch | 网页内容抓取 |
|
||||||
|
|
||||||
|
## 构建与安装
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plugindev build
|
||||||
|
```
|
||||||
|
|
||||||
|
输出 `.hmap` 包到项目目录。
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
通过 pluginmgr HTTP API 安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||||
|
-F "package=@my-plugin.hmap"
|
||||||
|
```
|
||||||
|
|
||||||
|
或手动将 `.hmap` 放入插件目录后重启平台。
|
||||||
192
README_EN.md
Normal file
192
README_EN.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# HomeAgent SDK
|
||||||
|
|
||||||
|
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||||
|
|
||||||
|
## SDK API Surface
|
||||||
|
|
||||||
|
### Plugin Interface
|
||||||
|
|
||||||
|
Plugins implement the `Plugin` interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Plugin interface {
|
||||||
|
Name() string
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
Stop() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PluginSDK Methods
|
||||||
|
|
||||||
|
The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
||||||
|
|
||||||
|
| Category | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
|
||||||
|
| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask |
|
||||||
|
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
|
||||||
|
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
|
||||||
|
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
|
||||||
|
| Text Memory | `TextMemory()` | Access text memory API (chronological events) |
|
||||||
|
| Doc Memory | `DocMemory()` | Access document memory API (vector store) |
|
||||||
|
| Social Graph | `Social()` | Access social graph API (read-only for external plugins) |
|
||||||
|
| Knowledge | `Knowledge()` | Access knowledge base API |
|
||||||
|
| LLM | `LLM()` | Access LLM provider manager API |
|
||||||
|
| Settings | `Settings()` | Access settings API |
|
||||||
|
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||||||
|
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||||||
|
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||||||
|
|
||||||
|
### Stage Hooks
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Listen to all stage events globally
|
||||||
|
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||||||
|
|
||||||
|
// Listen only to this plugin's own tool calls (before_toolcall / after_toolcall only)
|
||||||
|
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Channels
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
Capability flags:
|
||||||
|
|
||||||
|
| Flag | Value | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `CapText` | 1 | Plain text output |
|
||||||
|
| `CapFile` | 2 | File output |
|
||||||
|
| `CapImage` | 4 | Image output |
|
||||||
|
| `CapAudio` | 8 | Audio output |
|
||||||
|
| `CapStructured` | 16 | Structured data output |
|
||||||
|
|
||||||
|
### IOInjector Channel Routing
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `InjectText(source, channel, text)` | Inject text, record to memory, route to specified channel |
|
||||||
|
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||||||
|
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||||||
|
|
||||||
|
`source` identifies the origin, `channel` specifies the target output channel.
|
||||||
|
|
||||||
|
### Triple Extended Fields
|
||||||
|
|
||||||
|
The Triple data structure includes additional fields:
|
||||||
|
|
||||||
|
- `Confidence` — confidence score (0.0–1.0)
|
||||||
|
- `SubjectType` — subject type
|
||||||
|
- `ObjectType` — object type
|
||||||
|
|
||||||
|
### New Constructor
|
||||||
|
|
||||||
|
`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
||||||
|
|
||||||
|
## plugindev Toolchain
|
||||||
|
|
||||||
|
`plugindev` provides full development workflow support:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
|
||||||
|
| `plugindev build` | Build plugin, output .hmap package |
|
||||||
|
| `plugindev clean` | Clean build artifacts |
|
||||||
|
| `plugindev debug` | Run plugin in local debug mode |
|
||||||
|
|
||||||
|
Supports both **Go** and **Lua** plugin languages.
|
||||||
|
|
||||||
|
### plg.json Manifest Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lang": "go",
|
||||||
|
"entry": "main.go",
|
||||||
|
"description": "Plugin description",
|
||||||
|
"channels": ["my-channel"],
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### .hmap Package Format
|
||||||
|
|
||||||
|
`.hmap` is a ZIP archive containing:
|
||||||
|
|
||||||
|
- `plugin.json` — plugin metadata
|
||||||
|
- `plugin.so` — Go compiled artifact (Linux)
|
||||||
|
- `plugin.dll` — Go compiled artifact (Windows)
|
||||||
|
- `main.lua` — Lua plugin entry (for Lua plugins)
|
||||||
|
|
||||||
|
## Plugin Lifecycle
|
||||||
|
|
||||||
|
### Start & Stop
|
||||||
|
|
||||||
|
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||||||
|
- `Stop() error` — Plugin shutdown, release resources
|
||||||
|
|
||||||
|
### Auto-Restart
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.SetAutoRestart(true)
|
||||||
|
// Query state
|
||||||
|
enabled := sdk.AutoRestart()
|
||||||
|
```
|
||||||
|
|
||||||
|
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||||
|
|
||||||
|
## Restricted SDK vs Full SDK
|
||||||
|
|
||||||
|
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||||||
|
|
||||||
|
| Restricted API | Allowed Operations |
|
||||||
|
|----------------|-------------------|
|
||||||
|
| `SocialAPI` | Read-only: `GetPerson`, `GetTrait`, `GetRelations`, `GetNetwork`, `ListPersons` |
|
||||||
|
| `EventSubscriber` | Subscribe-only: `Subscribe` (no `Publish`) |
|
||||||
|
|
||||||
|
Internal plugins (platform built-in) have full SDK access including SocialAPI write operations and EventPublisher.
|
||||||
|
|
||||||
|
## Example Plugins
|
||||||
|
|
||||||
|
| Plugin | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| a2a | Agent-to-Agent protocol communication |
|
||||||
|
| bili | Bilibili data fetching |
|
||||||
|
| editdoc | Document editing |
|
||||||
|
| files | File management |
|
||||||
|
| memo | Memo/notes |
|
||||||
|
| ocr | Optical character recognition |
|
||||||
|
| qq | QQ messaging integration |
|
||||||
|
| sanitizer | Content sanitization/safety filtering |
|
||||||
|
| web | Web browsing and interaction |
|
||||||
|
| webfetch | Web content fetching |
|
||||||
|
|
||||||
|
## Building & Installing
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plugindev build
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs a `.hmap` package to the project directory.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
Via pluginmgr HTTP API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||||
|
-F "package=@my-plugin.hmap"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||||
7
example/a2a/go.mod
Normal file
7
example/a2a/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module a2a
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/a2a/plg.json
Normal file
11
example/a2a/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "a2a",
|
||||||
|
"name_zh": "A2A 代理通信",
|
||||||
|
"name_en": "A2A Agent Communication",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["a2a", "agent", "interop"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
370
example/a2a/plugin.go
Normal file
370
example/a2a/plugin.go
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
server *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin." + p.name + ".listen", Default: "127.0.0.1:12000",
|
||||||
|
Type: "string", DisplayName: "监听地址",
|
||||||
|
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Outbound: query + discover
|
||||||
|
s.RegisterTool(tp+"a2a_query", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_query", Description: "向另一个 A2A Agent 发送查询并获取回复",
|
||||||
|
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 的文本查询"},
|
||||||
|
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||||
|
},
|
||||||
|
"required": []string{"agent_url", "query"},
|
||||||
|
},
|
||||||
|
}, p.handleA2AQuery)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_discover", Description: "获取另一个 A2A Agent 的能力描述(Agent Card)",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
|
},
|
||||||
|
"required": []string{"agent_url"},
|
||||||
|
},
|
||||||
|
}, p.handleA2ADiscover)
|
||||||
|
|
||||||
|
// Inbound HTTP server
|
||||||
|
if addr, _ := s.Settings().Get("plugin." + p.name + ".listen"); addr != nil {
|
||||||
|
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||||
|
p.startServer(addrStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s] started", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
if p.server != nil {
|
||||||
|
p.server.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Inbound HTTP Server ----
|
||||||
|
|
||||||
|
func (p *Plugin) startServer(addr string) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||||
|
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||||
|
mux.HandleFunc("/a2a", p.handleIncomingA2A)
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] listen %s: %v", p.name, addr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.server = &http.Server{Handler: mux}
|
||||||
|
go func() {
|
||||||
|
log.Printf("[%s] A2A server on %s", p.name, listener.Addr())
|
||||||
|
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("[%s] serve: %v", p.name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
card := map[string]interface{}{
|
||||||
|
"name": p.name,
|
||||||
|
"description": "HomeAgent A2A Agent - 支持多工具调用与记忆管理",
|
||||||
|
"url": r.Host,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capabilities": []map[string]string{
|
||||||
|
{"id": "a2a_query", "name": "查询", "description": "接收并处理文本查询"},
|
||||||
|
{"id": "a2a_stream", "name": "流式响应", "description": "支持 SSE 流式回复"},
|
||||||
|
},
|
||||||
|
"skills": []map[string]string{
|
||||||
|
{"id": "chat", "name": "对话", "description": "通用对话与问题回答"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
p.handleAgentCard(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
Message *struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"message,omitempty"`
|
||||||
|
} `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "tasks.send":
|
||||||
|
// Extract query text
|
||||||
|
queryText := req.Params.Query
|
||||||
|
if queryText == "" && req.Params.Message != nil {
|
||||||
|
for _, part := range req.Params.Message.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
queryText += part.Text + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryText = strings.TrimSpace(queryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
||||||
|
if queryText != "" {
|
||||||
|
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with task accepted
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.ID,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||||
|
"status": "submitted",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
|
||||||
|
case "tasks.get":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
|
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown method", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleIncomingTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p.handleIncomingA2A(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- A2A Protocol Types ----
|
||||||
|
|
||||||
|
type A2AAgentCard struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Capabilities []A2ACapability `json:"capabilities,omitempty"`
|
||||||
|
Skills []A2ASkill `json:"skills,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2ACapability struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2ASkill struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema string `json:"input_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2ARequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params A2AParams `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AParams struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Result *A2AResult `json:"result,omitempty"`
|
||||||
|
Error *A2AError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AResult struct {
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Parts []A2APart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2APart struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Outbound Handlers ----
|
||||||
|
|
||||||
|
func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, error) {
|
||||||
|
agentURL, _ := args["agent_url"].(string)
|
||||||
|
agentURL = strings.TrimRight(agentURL, "/")
|
||||||
|
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||||
|
agentURL = "http://" + agentURL
|
||||||
|
}
|
||||||
|
|
||||||
|
cardURL := agentURL
|
||||||
|
if !strings.HasSuffix(cardURL, "/agent-card") {
|
||||||
|
cardURL = agentURL + "/agent-card"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(cardURL)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("连接失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var card A2AAgentCard
|
||||||
|
if err := json.Unmarshal(body, &card); err != nil {
|
||||||
|
var fallback map[string]interface{}
|
||||||
|
if err2 := json.Unmarshal(body, &fallback); err2 == nil {
|
||||||
|
return map[string]interface{}{"agent_info": fallback, "format": "非标准格式"}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("解析失败: %v", err), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"name": card.Name, "description": card.Description,
|
||||||
|
"version": card.Version, "url": card.URL,
|
||||||
|
"capabilities": card.Capabilities, "skills": card.Skills,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||||
|
agentURL, _ := args["agent_url"].(string)
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
timeoutSec := 60
|
||||||
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
|
timeoutSec = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentURL = strings.TrimRight(agentURL, "/")
|
||||||
|
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||||
|
agentURL = "http://" + agentURL
|
||||||
|
}
|
||||||
|
|
||||||
|
taskURL := agentURL
|
||||||
|
if strings.HasSuffix(agentURL, "/agent-card") {
|
||||||
|
taskURL = strings.TrimSuffix(agentURL, "/agent-card")
|
||||||
|
}
|
||||||
|
taskURL = strings.TrimRight(taskURL, "/") + "/task"
|
||||||
|
|
||||||
|
reqBody := A2ARequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||||
|
Method: "tasks.send",
|
||||||
|
Params: A2AParams{
|
||||||
|
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyData, _ := json.Marshal(reqBody)
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||||
|
resp, err := client.Post(taskURL, "application/json", bytes.NewReader(bodyData))
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var a2aResp A2AResponse
|
||||||
|
if err := json.Unmarshal(body, &a2aResp); err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if a2aResp.Error != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("Agent错误 [%d]: %s", a2aResp.Error.Code, a2aResp.Error.Message)}, nil
|
||||||
|
}
|
||||||
|
if a2aResp.Result == nil {
|
||||||
|
return map[string]interface{}{"error": "空结果", "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyText string
|
||||||
|
if a2aResp.Result.Message != nil {
|
||||||
|
for _, part := range a2aResp.Result.Message.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
replyText += part.Text + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replyText = strings.TrimSpace(replyText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||||
|
"response": replyText,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
7
example/bili/go.mod
Normal file
7
example/bili/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module bili
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/bili/plg.json
Normal file
11
example/bili/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "bili",
|
||||||
|
"name_zh": "B站视频下载",
|
||||||
|
"name_en": "Bilibili Video Downloader",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["bili", "video", "download"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
234
example/bili/plugin.go
Normal file
234
example/bili/plugin.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin." + p.name + ".output_dir", Default: "/tmp/bili_videos",
|
||||||
|
Type: "string", DisplayName: "下载目录",
|
||||||
|
Description: "B站视频下载后的保存目录",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||||
|
Name: tp + "video",
|
||||||
|
Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"},
|
||||||
|
"info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"},
|
||||||
|
"format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleBiliVideo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
type ytdlpFormat struct {
|
||||||
|
FormatID string `json:"format_id"`
|
||||||
|
FormatNote string `json:"format_note"`
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
TBR float64 `json:"tbr"`
|
||||||
|
Filesize int64 `json:"filesize"`
|
||||||
|
FilesizeApprox int64 `json:"filesize_approx"`
|
||||||
|
VCodec string `json:"vcodec"`
|
||||||
|
ACodec string `json:"acodec"`
|
||||||
|
FPS float64 `json:"fps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ytdlpInfo struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
WebpageURL string `json:"webpage_url"`
|
||||||
|
Filename string `json:"_filename"`
|
||||||
|
Formats []ytdlpFormat `json:"formats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
|
||||||
|
url, _ := args["url"].(string)
|
||||||
|
if url == "" {
|
||||||
|
return nil, fmt.Errorf("url is required")
|
||||||
|
}
|
||||||
|
infoOnly, _ := args["info_only"].(bool)
|
||||||
|
format, _ := args["format"].(string)
|
||||||
|
|
||||||
|
outputDir := "/tmp/bili_videos"
|
||||||
|
if p.sdk != nil {
|
||||||
|
if v, _ := p.sdk.Settings().Get("plugin." + p.name + ".output_dir"); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
outputDir = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
||||||
|
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &out
|
||||||
|
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ytdlpInfo
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &info); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if infoOnly {
|
||||||
|
var filtered []ytdlpFormat
|
||||||
|
for _, f := range info.Formats {
|
||||||
|
if f.VCodec != "none" || f.ACodec != "none" {
|
||||||
|
filtered = append(filtered, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.Formats = filtered
|
||||||
|
|
||||||
|
lines := []string{fmt.Sprintf("标题: %s", info.Title)}
|
||||||
|
if info.Duration > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
type fmtLine struct {
|
||||||
|
ID string
|
||||||
|
Note string
|
||||||
|
Res string
|
||||||
|
Ext string
|
||||||
|
Size string
|
||||||
|
}
|
||||||
|
var seen []string
|
||||||
|
var display []fmtLine
|
||||||
|
for _, f := range info.Formats {
|
||||||
|
if f.FormatNote == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := f.FormatNote + f.Ext
|
||||||
|
if contains(seen, key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen = append(seen, key)
|
||||||
|
res := ""
|
||||||
|
if f.Width > 0 && f.Height > 0 {
|
||||||
|
res = fmt.Sprintf("%dx%d", f.Width, f.Height)
|
||||||
|
}
|
||||||
|
sz := ""
|
||||||
|
fs := f.Filesize
|
||||||
|
if fs == 0 {
|
||||||
|
fs = f.FilesizeApprox
|
||||||
|
}
|
||||||
|
if fs > 0 {
|
||||||
|
sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576)
|
||||||
|
}
|
||||||
|
display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz})
|
||||||
|
}
|
||||||
|
if len(display) > 0 {
|
||||||
|
lines = append(lines, "清晰度列表:")
|
||||||
|
for _, d := range display {
|
||||||
|
r := d.Res
|
||||||
|
if r != "" {
|
||||||
|
r = " " + r
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dlArgs := []string{
|
||||||
|
"--no-warnings",
|
||||||
|
"--socket-timeout", "30",
|
||||||
|
"--retries", "3",
|
||||||
|
"--fragment-retries", "3",
|
||||||
|
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
||||||
|
"--no-overwrites",
|
||||||
|
}
|
||||||
|
if format != "" {
|
||||||
|
dlArgs = append(dlArgs, "-f", format)
|
||||||
|
}
|
||||||
|
dlArgs = append(dlArgs, url)
|
||||||
|
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
||||||
|
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||||
|
var dlOut bytes.Buffer
|
||||||
|
cmd2.Stdout = &dlOut
|
||||||
|
cmd2.Stderr = &dlOut
|
||||||
|
if err := cmd2.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _ := os.ReadDir(outputDir)
|
||||||
|
var newest string
|
||||||
|
var newestTime int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, _ := e.Info()
|
||||||
|
if fi == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := fi.ModTime().Unix()
|
||||||
|
if t > newestTime {
|
||||||
|
newestTime = t
|
||||||
|
newest = e.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newest == "" {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "下载完成,但未找到视频文件",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
dlPath := filepath.Join(outputDir, newest)
|
||||||
|
fi, _ := os.Stat(dlPath)
|
||||||
|
var fileSize int64
|
||||||
|
if fi != nil {
|
||||||
|
fileSize = fi.Size()
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
||||||
|
"file": dlPath,
|
||||||
|
"filename": newest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, s string) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
101
example/bili/plugin.h
Normal file
101
example/bili/plugin.h
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/* Code generated by cmd/cgo; DO NOT EDIT. */
|
||||||
|
|
||||||
|
/* package bili */
|
||||||
|
|
||||||
|
|
||||||
|
#line 1 "cgo-builtin-export-prolog"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifndef GO_CGO_EXPORT_PROLOGUE_H
|
||||||
|
#define GO_CGO_EXPORT_PROLOGUE_H
|
||||||
|
|
||||||
|
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||||
|
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
||||||
|
extern size_t _GoStringLen(_GoString_ s);
|
||||||
|
extern const char *_GoStringPtr(_GoString_ s);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Start of preamble from import "C" comments. */
|
||||||
|
|
||||||
|
|
||||||
|
#line 3 "z_bridge_gen.go"
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||||
|
|
||||||
|
#line 1 "cgo-generated-wrapper"
|
||||||
|
|
||||||
|
|
||||||
|
/* End of preamble from import "C" comments. */
|
||||||
|
|
||||||
|
|
||||||
|
/* Start of boilerplate cgo prologue. */
|
||||||
|
#line 1 "cgo-gcc-export-header-prolog"
|
||||||
|
|
||||||
|
#ifndef GO_CGO_PROLOGUE_H
|
||||||
|
#define GO_CGO_PROLOGUE_H
|
||||||
|
|
||||||
|
typedef signed char GoInt8;
|
||||||
|
typedef unsigned char GoUint8;
|
||||||
|
typedef short GoInt16;
|
||||||
|
typedef unsigned short GoUint16;
|
||||||
|
typedef int GoInt32;
|
||||||
|
typedef unsigned int GoUint32;
|
||||||
|
typedef long long GoInt64;
|
||||||
|
typedef unsigned long long GoUint64;
|
||||||
|
typedef GoInt64 GoInt;
|
||||||
|
typedef GoUint64 GoUint;
|
||||||
|
typedef size_t GoUintptr;
|
||||||
|
typedef float GoFloat32;
|
||||||
|
typedef double GoFloat64;
|
||||||
|
#ifdef _MSC_VER
|
||||||
|
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
||||||
|
#include <complex.h>
|
||||||
|
typedef _Fcomplex GoComplex64;
|
||||||
|
typedef _Dcomplex GoComplex128;
|
||||||
|
#else
|
||||||
|
#include <complex>
|
||||||
|
typedef std::complex<float> GoComplex64;
|
||||||
|
typedef std::complex<double> GoComplex128;
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
|
typedef float _Complex GoComplex64;
|
||||||
|
typedef double _Complex GoComplex128;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
static assertion to make sure the file is being used on architecture
|
||||||
|
at least with matching size of GoInt.
|
||||||
|
*/
|
||||||
|
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
|
||||||
|
|
||||||
|
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||||
|
typedef _GoString_ GoString;
|
||||||
|
#endif
|
||||||
|
typedef void *GoMap;
|
||||||
|
typedef void *GoChan;
|
||||||
|
typedef struct { void *t; void *v; } GoInterface;
|
||||||
|
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* End of boilerplate cgo prologue. */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
|
||||||
|
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
|
||||||
|
extern int go_stop_plugin(char** errorOut);
|
||||||
|
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
|
||||||
|
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
|
||||||
|
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
|
||||||
|
extern void go_free_string(char* ptr);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
7
example/editdoc/go.mod
Normal file
7
example/editdoc/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module editdoc
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/editdoc/plg.json
Normal file
11
example/editdoc/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "editdoc",
|
||||||
|
"name_zh": "文档编辑",
|
||||||
|
"name_en": "Document Editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["editdoc", "office", "document"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
129
example/editdoc/plugin.go
Normal file
129
example/editdoc/plugin.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||||
|
Name: "edit_document",
|
||||||
|
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
|
||||||
|
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
|
||||||
|
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
|
||||||
|
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
|
||||||
|
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
|
||||||
|
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
|
||||||
|
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
|
||||||
|
"value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"},
|
||||||
|
},
|
||||||
|
"required": []string{"file", "operation"},
|
||||||
|
},
|
||||||
|
}, p.handleEditDocument)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
|
||||||
|
file, _ := args["file"].(string)
|
||||||
|
if file == "" {
|
||||||
|
return nil, fmt.Errorf("file is required")
|
||||||
|
}
|
||||||
|
operation, _ := args["operation"].(string)
|
||||||
|
if operation == "" {
|
||||||
|
return nil, fmt.Errorf("operation is required")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("文件不存在: %s", file),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pyArgs := map[string]interface{}{}
|
||||||
|
if v, ok := args["target"]; ok {
|
||||||
|
pyArgs["target"] = v
|
||||||
|
}
|
||||||
|
if v, ok := args["replacement"]; ok {
|
||||||
|
pyArgs["replacement"] = v
|
||||||
|
}
|
||||||
|
if v, ok := args["sheet"]; ok {
|
||||||
|
pyArgs["sheet"] = v
|
||||||
|
}
|
||||||
|
if v, ok := args["row"]; ok {
|
||||||
|
pyArgs["row"] = v
|
||||||
|
}
|
||||||
|
if v, ok := args["col"]; ok {
|
||||||
|
pyArgs["col"] = v
|
||||||
|
}
|
||||||
|
if v, ok := args["value"]; ok {
|
||||||
|
pyArgs["value"] = v
|
||||||
|
}
|
||||||
|
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||||
|
|
||||||
|
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py"
|
||||||
|
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3"
|
||||||
|
pythonBin := "python3"
|
||||||
|
if _, err := os.Stat(venvPython); err == nil {
|
||||||
|
pythonBin = venvPython
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON))
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("edit document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
|
||||||
|
"file": file,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !result.Ok {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("编辑失败: %s", result.Error),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
|
||||||
|
if result.Count > 0 {
|
||||||
|
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": msg,
|
||||||
|
"file": file,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
11
example/files/plg.json
Normal file
11
example/files/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"name_zh": "文件系统",
|
||||||
|
"name_en": "File System",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["files", "filesystem"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
483
example/files/plugin.go
Normal file
483
example/files/plugin.go
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
mu sync.RWMutex
|
||||||
|
filesDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.files.dir",
|
||||||
|
Default: "/",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "文件系统根目录",
|
||||||
|
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||||
|
Category: "files",
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||||
|
if strings.HasPrefix(dir, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
dir = filepath.Join(home, dir[2:])
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve files.dir: %w", err)
|
||||||
|
}
|
||||||
|
p.filesDir = abs
|
||||||
|
os.MkdirAll(p.filesDir, 0755)
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||||
|
Name: tp + "read",
|
||||||
|
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
|
||||||
|
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
|
||||||
|
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
}, p.handleRead)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"write", sdk.ToolDef{
|
||||||
|
Name: tp + "write",
|
||||||
|
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "File path"},
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
|
||||||
|
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
|
||||||
|
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
},
|
||||||
|
}, p.handleWrite)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"edit", sdk.ToolDef{
|
||||||
|
Name: tp + "edit",
|
||||||
|
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
|
||||||
|
"edits": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
|
||||||
|
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
|
||||||
|
},
|
||||||
|
"required": []string{"old", "new"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "edits"},
|
||||||
|
},
|
||||||
|
}, p.handleEdit)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"ls", sdk.ToolDef{
|
||||||
|
Name: tp + "ls",
|
||||||
|
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
|
||||||
|
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, p.handleLs)
|
||||||
|
|
||||||
|
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath resolves user-provided path to an absolute path within filesDir.
|
||||||
|
func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||||
|
if userPath == "" {
|
||||||
|
userPath = "."
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(userPath) {
|
||||||
|
userPath = filepath.Join(p.filesDir, userPath)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(userPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve path: %w", err)
|
||||||
|
}
|
||||||
|
base := filepath.Clean(p.filesDir)
|
||||||
|
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||||
|
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRead implements the read tool.
|
||||||
|
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return errorResult("path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("file not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("stat error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return errorResult("is a directory, use ls instead: " + path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(data)
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
totalLines := len(lines)
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if v, ok := args["offset"].(float64); ok && v > 0 {
|
||||||
|
offset = int(v) - 1
|
||||||
|
}
|
||||||
|
if offset >= totalLines {
|
||||||
|
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := totalLines - offset
|
||||||
|
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||||
|
if int(v) < limit {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
end := offset + limit
|
||||||
|
if end > totalLines {
|
||||||
|
end = totalLines
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := lines[offset:end]
|
||||||
|
output := strings.Join(selected, "\n")
|
||||||
|
|
||||||
|
truncated := false
|
||||||
|
if limit < totalLines-offset {
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(output)
|
||||||
|
if truncated {
|
||||||
|
nextOffset := end + 1
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
|
||||||
|
} else if offset > 0 || end < totalLines {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": sb.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWrite implements the write tool.
|
||||||
|
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return errorResult("path is required"), nil
|
||||||
|
}
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
mode, _ := args["mode"].(string)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "overwrite"
|
||||||
|
}
|
||||||
|
|
||||||
|
line := 0
|
||||||
|
if v, ok := args["line"].(float64); ok && v > 0 {
|
||||||
|
line = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "create":
|
||||||
|
if _, err := os.Stat(absPath); err == nil {
|
||||||
|
return errorResult("file already exists: " + path), nil
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return errorResult("mkdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "append":
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return errorResult("mkdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("open error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
return errorResult("append error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "insert":
|
||||||
|
if line < 1 {
|
||||||
|
return errorResult("line must be >= 1 for insert mode"), nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("file not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
if line > len(lines)+1 {
|
||||||
|
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
|
||||||
|
}
|
||||||
|
idx := line - 1
|
||||||
|
newLines := make([]string, 0, len(lines)+1)
|
||||||
|
newLines = append(newLines, lines[:idx]...)
|
||||||
|
newLines = append(newLines, content)
|
||||||
|
newLines = append(newLines, lines[idx:]...)
|
||||||
|
result := strings.Join(newLines, "\n")
|
||||||
|
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default: // overwrite
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return errorResult("mkdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEdit implements the edit tool.
|
||||||
|
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return errorResult("path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawEdits, ok := args["edits"].([]interface{})
|
||||||
|
if !ok || len(rawEdits) == 0 {
|
||||||
|
return errorResult("edits must be a non-empty array"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("file not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
original := string(data)
|
||||||
|
content := original
|
||||||
|
applied := 0
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for i, raw := range rawEdits {
|
||||||
|
edit, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldText, _ := edit["old"].(string)
|
||||||
|
newText, _ := edit["new"].(string)
|
||||||
|
if oldText == "" {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count := strings.Count(content, oldText)
|
||||||
|
if count == 0 {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count > 1 {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content = strings.Replace(content, oldText, newText, 1)
|
||||||
|
applied++
|
||||||
|
}
|
||||||
|
|
||||||
|
if applied == 0 {
|
||||||
|
msg := "no edits applied"
|
||||||
|
if len(errors) > 0 {
|
||||||
|
msg += ": " + strings.Join(errors, "; ")
|
||||||
|
}
|
||||||
|
return errorResult(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
|
||||||
|
if len(errors) > 0 {
|
||||||
|
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": msg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLs implements the ls tool.
|
||||||
|
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
path = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("path not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("stat error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return errorResult("not a directory: " + path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("readdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 500
|
||||||
|
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
||||||
|
})
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
entryLimitReached := false
|
||||||
|
for i, entry := range entries {
|
||||||
|
if i >= limit {
|
||||||
|
entryLimitReached = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
lines = append(lines, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "(empty directory)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output := strings.Join(lines, "\n")
|
||||||
|
if entryLimitReached {
|
||||||
|
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": output,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorResult returns a standardized error result.
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"isError": true,
|
||||||
|
"content": msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSetting reads a setting with generic type assertion.
|
||||||
|
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||||
|
v, err := s.Get(key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
val, ok := v.(T)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
7
example/memo/go.mod
Normal file
7
example/memo/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module memo
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/memo/plg.json
Normal file
11
example/memo/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "memo",
|
||||||
|
"name_zh": "备忘录",
|
||||||
|
"name_en": "Memo/Notes",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["memo", "todo", "notes"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
274
example/memo/plugin.go
Normal file
274
example/memo/plugin.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Memo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
mu sync.RWMutex
|
||||||
|
memos []Memo
|
||||||
|
nextID int64
|
||||||
|
filePath string
|
||||||
|
stopCh chan struct{}
|
||||||
|
tp string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
p.tp = p.name + "_"
|
||||||
|
p.stopCh = make(chan struct{})
|
||||||
|
|
||||||
|
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||||
|
if err != nil || dataDirVal == "" {
|
||||||
|
dataDirVal = "."
|
||||||
|
}
|
||||||
|
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
||||||
|
p.load()
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"create", sdk.ToolDef{
|
||||||
|
Name: p.tp + "create",
|
||||||
|
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
|
||||||
|
},
|
||||||
|
"required": []string{"content"},
|
||||||
|
},
|
||||||
|
}, p.handleCreate)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
|
||||||
|
Name: p.tp + "complete",
|
||||||
|
Description: "将指定ID的备忘标记为已完成。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, p.handleComplete)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"list", sdk.ToolDef{
|
||||||
|
Name: p.tp + "list",
|
||||||
|
Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleList)
|
||||||
|
|
||||||
|
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||||
|
|
||||||
|
go p.periodicCheck()
|
||||||
|
|
||||||
|
log.Printf("[%s] started, path=%s", p.name, p.filePath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
close(p.stopCh)
|
||||||
|
p.save()
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) load() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
data, err := os.ReadFile(p.filePath)
|
||||||
|
if err != nil {
|
||||||
|
p.memos = nil
|
||||||
|
p.nextID = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var store struct {
|
||||||
|
Memos []Memo `json:"memos"`
|
||||||
|
NextID int64 `json:"next_id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &store) != nil {
|
||||||
|
p.memos = nil
|
||||||
|
p.nextID = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.memos = store.Memos
|
||||||
|
p.nextID = store.NextID
|
||||||
|
if p.memos == nil {
|
||||||
|
p.memos = []Memo{}
|
||||||
|
}
|
||||||
|
if p.nextID < 1 {
|
||||||
|
p.nextID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) save() {
|
||||||
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
|
"memos": p.memos,
|
||||||
|
"next_id": p.nextID,
|
||||||
|
}, "", " ")
|
||||||
|
os.WriteFile(p.filePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) pendingCount() int {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
n := 0
|
||||||
|
for _, m := range p.memos {
|
||||||
|
if !m.Done {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) pendingMemos() []Memo {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
var out []Memo
|
||||||
|
for _, m := range p.memos {
|
||||||
|
if !m.Done {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||||
|
n := p.pendingCount()
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||||
|
"role": "system",
|
||||||
|
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
||||||
|
})
|
||||||
|
ctx.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) periodicCheck() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
n := p.pendingCount()
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.sdk != nil {
|
||||||
|
p.sdk.InjectInterruptText(p.name, p.name,
|
||||||
|
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
if content == "" {
|
||||||
|
return errorResult("content is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
memo := Memo{
|
||||||
|
ID: p.nextID,
|
||||||
|
Content: content,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
Done: false,
|
||||||
|
}
|
||||||
|
p.nextID++
|
||||||
|
p.memos = append(p.memos, memo)
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.save()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
|
||||||
|
"id": memo.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, ok := args["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return errorResult("id is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
found := false
|
||||||
|
for i := range p.memos {
|
||||||
|
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
|
||||||
|
p.memos[i].Done = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
|
||||||
|
}
|
||||||
|
p.save()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
memos := p.pendingMemos()
|
||||||
|
if len(memos) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "暂无未完成的备忘",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, m := range memos {
|
||||||
|
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": sb.String(),
|
||||||
|
"count": len(memos),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"isError": true,
|
||||||
|
"content": msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
7
example/ocr/go.mod
Normal file
7
example/ocr/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module ocr
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/ocr/plg.json
Normal file
11
example/ocr/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "ocr",
|
||||||
|
"name_zh": "OCR 文字识别",
|
||||||
|
"name_en": "OCR Text Recognition",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "图片文字识别工具,基于 Tesseract OCR 引擎。支持从 URL 或 base64 图片中提取文字内容。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["ocr", "image", "text"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
134
example/ocr/plugin.go
Normal file
134
example/ocr/plugin.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"ocr_image", sdk.ToolDef{
|
||||||
|
Name: tp + "ocr_image",
|
||||||
|
Description: "对图片进行OCR文字识别,支持中文和英文。可传入图片URL或base64编码。返回识别出的文本内容。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"image_url": map[string]interface{}{"type": "string", "description": "图片的HTTP/HTTPS URL,与 image_data 二选一"},
|
||||||
|
"image_data": map[string]interface{}{"type": "string", "description": "图片的base64编码数据(不含 data:image/... 前缀),与 image_url 二选一"},
|
||||||
|
"language": map[string]interface{}{"type": "string", "description": "识别语言,默认 chi_sim+eng(中文简体+英文),可选 chi_sim / eng / chi_sim+eng"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, p.handleOcrImage)
|
||||||
|
|
||||||
|
log.Printf("[%s] plugin started", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error) {
|
||||||
|
imageURL, _ := args["image_url"].(string)
|
||||||
|
imageData, _ := args["image_data"].(string)
|
||||||
|
language, _ := args["language"].(string)
|
||||||
|
|
||||||
|
if imageURL == "" && imageData == "" {
|
||||||
|
return map[string]interface{}{"error": "请提供 image_url 或 image_data"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "ocr-*")
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("创建临时目录失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
inputPath := filepath.Join(tmpDir, "input.png")
|
||||||
|
|
||||||
|
if imageData != "" {
|
||||||
|
data := strings.TrimSpace(imageData)
|
||||||
|
if idx := strings.Index(data, "base64,"); idx >= 0 {
|
||||||
|
data = data[idx+7:]
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("base64解码失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(inputPath, decoded, 0644); err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("写入临时文件失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(imageURL)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("下载图片失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("下载图片返回状态码 %d", resp.StatusCode)}, nil
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("读取图片数据失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(inputPath, data, 0644); err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("写入临时文件失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if language == "" {
|
||||||
|
language = "chi_sim+eng"
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath := filepath.Join(tmpDir, "output")
|
||||||
|
|
||||||
|
argsList := []string{inputPath, outputPath, "-l", language, "--psm", "3"}
|
||||||
|
cmd := exec.Command("tesseract", argsList...)
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("OCR识别失败: %v (stderr: %s)", err, stderr.String())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resultFile := outputPath + ".txt"
|
||||||
|
text, err := os.ReadFile(resultFile)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("读取OCR结果失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
recognized := strings.TrimSpace(string(text))
|
||||||
|
if recognized == "" {
|
||||||
|
return map[string]interface{}{"text": "", "message": "未识别出文字内容"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"text": recognized,
|
||||||
|
"length": len(recognized),
|
||||||
|
"language": language,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
7
example/qq/go.mod
Normal file
7
example/qq/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module qq
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/qq/plg.json
Normal file
11
example/qq/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "qq",
|
||||||
|
"name_zh": "QQ消息",
|
||||||
|
"name_en": "qq",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["qq", "messaging"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
1977
example/qq/plugin.go
Normal file
1977
example/qq/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
1728
example/qq/plugin.go.bak
Normal file
1728
example/qq/plugin.go.bak
Normal file
File diff suppressed because it is too large
Load Diff
101
example/qq/plugin.h
Normal file
101
example/qq/plugin.h
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/* Code generated by cmd/cgo; DO NOT EDIT. */
|
||||||
|
|
||||||
|
/* package qq */
|
||||||
|
|
||||||
|
|
||||||
|
#line 1 "cgo-builtin-export-prolog"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifndef GO_CGO_EXPORT_PROLOGUE_H
|
||||||
|
#define GO_CGO_EXPORT_PROLOGUE_H
|
||||||
|
|
||||||
|
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||||
|
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
||||||
|
extern size_t _GoStringLen(_GoString_ s);
|
||||||
|
extern const char *_GoStringPtr(_GoString_ s);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Start of preamble from import "C" comments. */
|
||||||
|
|
||||||
|
|
||||||
|
#line 3 "z_bridge_gen.go"
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||||
|
|
||||||
|
#line 1 "cgo-generated-wrapper"
|
||||||
|
|
||||||
|
|
||||||
|
/* End of preamble from import "C" comments. */
|
||||||
|
|
||||||
|
|
||||||
|
/* Start of boilerplate cgo prologue. */
|
||||||
|
#line 1 "cgo-gcc-export-header-prolog"
|
||||||
|
|
||||||
|
#ifndef GO_CGO_PROLOGUE_H
|
||||||
|
#define GO_CGO_PROLOGUE_H
|
||||||
|
|
||||||
|
typedef signed char GoInt8;
|
||||||
|
typedef unsigned char GoUint8;
|
||||||
|
typedef short GoInt16;
|
||||||
|
typedef unsigned short GoUint16;
|
||||||
|
typedef int GoInt32;
|
||||||
|
typedef unsigned int GoUint32;
|
||||||
|
typedef long long GoInt64;
|
||||||
|
typedef unsigned long long GoUint64;
|
||||||
|
typedef GoInt64 GoInt;
|
||||||
|
typedef GoUint64 GoUint;
|
||||||
|
typedef size_t GoUintptr;
|
||||||
|
typedef float GoFloat32;
|
||||||
|
typedef double GoFloat64;
|
||||||
|
#ifdef _MSC_VER
|
||||||
|
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
||||||
|
#include <complex.h>
|
||||||
|
typedef _Fcomplex GoComplex64;
|
||||||
|
typedef _Dcomplex GoComplex128;
|
||||||
|
#else
|
||||||
|
#include <complex>
|
||||||
|
typedef std::complex<float> GoComplex64;
|
||||||
|
typedef std::complex<double> GoComplex128;
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
|
typedef float _Complex GoComplex64;
|
||||||
|
typedef double _Complex GoComplex128;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
static assertion to make sure the file is being used on architecture
|
||||||
|
at least with matching size of GoInt.
|
||||||
|
*/
|
||||||
|
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
|
||||||
|
|
||||||
|
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||||
|
typedef _GoString_ GoString;
|
||||||
|
#endif
|
||||||
|
typedef void *GoMap;
|
||||||
|
typedef void *GoChan;
|
||||||
|
typedef struct { void *t; void *v; } GoInterface;
|
||||||
|
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* End of boilerplate cgo prologue. */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
|
||||||
|
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
|
||||||
|
extern int go_stop_plugin(char** errorOut);
|
||||||
|
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
|
||||||
|
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
|
||||||
|
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
|
||||||
|
extern void go_free_string(char* ptr);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
7
example/sanitizer/go.mod
Normal file
7
example/sanitizer/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module sanitizer
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||||
11
example/sanitizer/plg.json
Normal file
11
example/sanitizer/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "sanitizer",
|
||||||
|
"name_zh": "输出清洗",
|
||||||
|
"name_en": "sanitizer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "清洗 LLM 输出中的工具调用残留(思维泄漏)",
|
||||||
|
"author": "HomeAgent SDK",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["sanitizer"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
102
example/sanitizer/plugin.go
Normal file
102
example/sanitizer/plugin.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
|
||||||
|
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||||
|
//
|
||||||
|
// 编译:
|
||||||
|
//
|
||||||
|
// go build -buildmode=plugin -o sanitizer.so .
|
||||||
|
//
|
||||||
|
// 安装到 HomeAgent 插件目录(如 plugins/sanitizer/plugin.so),
|
||||||
|
// HomeAgent 自动通过 tryLoadSO 加载。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||||
|
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||||
|
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||||
|
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||||
|
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||||
|
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
|
||||||
|
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||||
|
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||||
|
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||||
|
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct{}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return "sanitizer" }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
|
||||||
|
ctx.Lock()
|
||||||
|
before := len(ctx.LLMText)
|
||||||
|
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
|
||||||
|
after := len(ctx.LLMText)
|
||||||
|
ctx.Unlock()
|
||||||
|
if before != after {
|
||||||
|
log.Printf("[sanitizer] cleaned %d bytes (before=%d after=%d)", before-after, before, after)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
log.Printf("[sanitizer] stage PostAction registered")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanToolCallLeakage(content string) string {
|
||||||
|
if content == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
before := len(content)
|
||||||
|
|
||||||
|
content = toolCodeBlockRE.ReplaceAllString(content, "")
|
||||||
|
content = invokeCodeBlockRE.ReplaceAllString(content, "")
|
||||||
|
content = toolCodeBlockRE2.ReplaceAllString(content, "")
|
||||||
|
|
||||||
|
content = toolCallTagRE.ReplaceAllString(content, "")
|
||||||
|
content = invokeTagRE.ReplaceAllString(content, "")
|
||||||
|
content = toolTagRE.ReplaceAllString(content, "")
|
||||||
|
content = functionTagRE.ReplaceAllString(content, "")
|
||||||
|
|
||||||
|
content = chineseMarkerRE.ReplaceAllString(content, "")
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
cleaned = append(cleaned, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if toolNameRE.MatchString(trimmed) {
|
||||||
|
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, line)
|
||||||
|
}
|
||||||
|
content = strings.Join(cleaned, "\n")
|
||||||
|
|
||||||
|
content = multiNewlineRE.ReplaceAllString(content, "\n\n")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
if len(content) != before {
|
||||||
|
log.Printf("[sanitizer] cleanToolCallLeakage: %d bytes removed", before-len(content))
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
31
example/sanitizer/sanitizer_test.go
Normal file
31
example/sanitizer/sanitizer_test.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCleanToolCallLeakage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, input, want string
|
||||||
|
}{
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"clean", "你好", "你好"},
|
||||||
|
{"tool_call", "a<tool_call>x</tool_call>b", "ab"},
|
||||||
|
{"invoke", "a<invoke>x</invoke>b", "ab"},
|
||||||
|
{"function", "a<function>x</function>b", "ab"},
|
||||||
|
{"xml_block", "a\n```xml\n<tool_call>x</tool_call>\n```\nb", "a\n\nb"},
|
||||||
|
{"json_block", "a\n```json\n<invoke>x</invoke>\n```\nb", "a\n\nb"},
|
||||||
|
{"bare_code", "```python\nprint(1)\n```", "```python\nprint(1)\n```"},
|
||||||
|
{"chinese_marker", "a【tool_call】x【/tool_call】b", "ab"},
|
||||||
|
{"tool_line", "cmd_run(\"ls\")\nok", "ok"},
|
||||||
|
{"prose_kept", "cmd_run 是一个工具", "cmd_run 是一个工具"},
|
||||||
|
{"multiline", "a\n<tool_call>\nx\n</tool_call>\nb", "a\n\nb"},
|
||||||
|
{"whitespace", "a\n\n\n\nb", "a\n\nb"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cleanToolCallLeakage(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
7
example/web/go.mod
Normal file
7
example/web/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module web
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
|
||||||
11
example/web/plg.json
Normal file
11
example/web/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"name_zh": "网络搜索",
|
||||||
|
"name_en": "web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "网络搜索与抓取工具(web_search/web_fetch)",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["web", "search", "fetch"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
568
example/web/plugin.go
Normal file
568
example/web/plugin.go
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
mu sync.RWMutex
|
||||||
|
timeout int
|
||||||
|
proxy string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: time.Duration(timeout) * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
||||||
|
ResponseHeaderTimeout: time.Duration(timeout) * time.Second,
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
u, err := url.Parse(proxyURL)
|
||||||
|
if err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Duration(timeout) * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 5 {
|
||||||
|
return fmt.Errorf("too many redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.web.timeout",
|
||||||
|
Default: "30",
|
||||||
|
Type: "int",
|
||||||
|
DisplayName: "HTTP 超时(秒)",
|
||||||
|
Description: "Web fetch 和搜索的 HTTP 请求超时时间",
|
||||||
|
Category: "web",
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.web.proxy",
|
||||||
|
Default: "",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "HTTP 代理",
|
||||||
|
Description: "HTTP 代理地址,如 http://<proxy-host>:<proxy-port>。为空则不使用代理",
|
||||||
|
Category: "web",
|
||||||
|
})
|
||||||
|
|
||||||
|
t := getSetting[float64](s.Settings(), "timeout", 30)
|
||||||
|
p.timeout = int(t)
|
||||||
|
if p.timeout < 5 {
|
||||||
|
p.timeout = 5
|
||||||
|
}
|
||||||
|
if p.timeout > 120 {
|
||||||
|
p.timeout = 120
|
||||||
|
}
|
||||||
|
|
||||||
|
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||||
|
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"search", sdk.ToolDef{
|
||||||
|
Name: tp + "search",
|
||||||
|
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"query": map[string]interface{}{"type": "string", "description": "Search query"},
|
||||||
|
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
|
||||||
|
},
|
||||||
|
"required": []string{"query"},
|
||||||
|
},
|
||||||
|
}, p.handleSearch)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"fetch", sdk.ToolDef{
|
||||||
|
Name: tp + "fetch",
|
||||||
|
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
|
||||||
|
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleFetch)
|
||||||
|
|
||||||
|
proxyMsg := ""
|
||||||
|
if p.proxy != "" {
|
||||||
|
proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy)
|
||||||
|
}
|
||||||
|
log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
p.client.CloseIdleConnections()
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSRF 保护 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
var privateCIDRs []*net.IPNet
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cidrs := []string{
|
||||||
|
"127.0.0.0/8", // loopback
|
||||||
|
"10.0.0.0/8", // private
|
||||||
|
"172.16.0.0/12", // private
|
||||||
|
"192.168.0.0/16", // private
|
||||||
|
"100.64.0.0/10", // carrier-grade NAT
|
||||||
|
"169.254.0.0/16", // link-local
|
||||||
|
"::1/128", // IPv6 loopback
|
||||||
|
"fc00::/7", // IPv6 unique local
|
||||||
|
"fe80::/10", // IPv6 link-local
|
||||||
|
}
|
||||||
|
for _, c := range cidrs {
|
||||||
|
_, n, err := net.ParseCIDR(c)
|
||||||
|
if err == nil {
|
||||||
|
privateCIDRs = append(privateCIDRs, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
for _, n := range privateCIDRs {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ssrfCheck(rawURL string) error {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := u.Hostname()
|
||||||
|
ips, err := net.LookupHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DNS lookup failed for %s: %w", host, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
if parsed == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isPrivateIP(parsed) {
|
||||||
|
return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DuckDuckGo 搜索 ────────────────────────────────────────
|
||||||
|
|
||||||
|
type ddgResult struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
Snippet string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
|
||||||
|
form := url.Values{"q": {query}}
|
||||||
|
req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseDDGResults(string(body), count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDDGResults(html string, count int) []ddgResult {
|
||||||
|
var results []ddgResult
|
||||||
|
|
||||||
|
// Find all result blocks: <div class="result__body"> ... </div>
|
||||||
|
bodyMarker := `result__body"`
|
||||||
|
for i := 0; i < len(html); i++ {
|
||||||
|
idx := strings.Index(html[i:], bodyMarker)
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += idx
|
||||||
|
|
||||||
|
// Find closing </div>
|
||||||
|
closeIdx := findClosingTag(html, i, "</div>")
|
||||||
|
if closeIdx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
block := html[i : closeIdx+6]
|
||||||
|
|
||||||
|
r := parseSingleDDGResult(block)
|
||||||
|
if r.URL != "" {
|
||||||
|
results = append(results, r)
|
||||||
|
if len(results) >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i = closeIdx + 6
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func findClosingTag(s string, start int, tag string) int {
|
||||||
|
depth := 1
|
||||||
|
pos := start
|
||||||
|
for pos < len(s) {
|
||||||
|
nextOpen := strings.Index(s[pos:], `<div`)
|
||||||
|
nextClose := strings.Index(s[pos:], tag)
|
||||||
|
if nextClose < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if nextOpen >= 0 && nextOpen < nextClose {
|
||||||
|
depth++
|
||||||
|
pos += nextOpen + 4
|
||||||
|
} else {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return pos + nextClose
|
||||||
|
}
|
||||||
|
pos += nextClose + len(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSingleDDGResult(block string) ddgResult {
|
||||||
|
var r ddgResult
|
||||||
|
|
||||||
|
// Extract URL and title from: <a rel="nofollow" class="result__a" href="URL">TITLE</a>
|
||||||
|
urlMarker := `class="result__a" href="`
|
||||||
|
uIdx := strings.Index(block, urlMarker)
|
||||||
|
if uIdx >= 0 {
|
||||||
|
start := uIdx + len(urlMarker)
|
||||||
|
end := strings.Index(block[start:], `"`)
|
||||||
|
if end >= 0 {
|
||||||
|
r.URL = block[start : start+end]
|
||||||
|
}
|
||||||
|
|
||||||
|
aStart := strings.Index(block[start+end:], `>`)
|
||||||
|
if aStart >= 0 {
|
||||||
|
titleStart := start + end + aStart + 1
|
||||||
|
aEnd := strings.Index(block[titleStart:], `</a>`)
|
||||||
|
if aEnd >= 0 {
|
||||||
|
r.Title = stripTags(block[titleStart : titleStart+aEnd])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract snippet: <a class="result__snippet" ...> ... </a>
|
||||||
|
snippetMarkers := []string{
|
||||||
|
`<a class="result__snippet`,
|
||||||
|
`<div class="result__snippet`,
|
||||||
|
}
|
||||||
|
for _, marker := range snippetMarkers {
|
||||||
|
sIdx := strings.Index(block, marker)
|
||||||
|
if sIdx >= 0 {
|
||||||
|
aStart := strings.Index(block[sIdx:], `>`)
|
||||||
|
if aStart >= 0 {
|
||||||
|
snipStart := sIdx + aStart + 1
|
||||||
|
snipEnd := strings.Index(block[snipStart:], `</a>`)
|
||||||
|
if snipEnd < 0 {
|
||||||
|
snipEnd = strings.Index(block[snipStart:], `</div>`)
|
||||||
|
}
|
||||||
|
if snipEnd >= 0 {
|
||||||
|
r.Snippet = stripTags(block[snipStart : snipStart+snipEnd])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Web Fetch ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
|
||||||
|
rawURL, _ := args["url"].(string)
|
||||||
|
if rawURL == "" {
|
||||||
|
return errorResult("url is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxChars := 20000
|
||||||
|
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
||||||
|
maxChars = int(v)
|
||||||
|
}
|
||||||
|
if maxChars > 500000 {
|
||||||
|
maxChars = 500000
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.ssrfCheck(rawURL); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("invalid URL: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("fetch failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawText := string(body)
|
||||||
|
|
||||||
|
// Extract readable content based on content type
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
var extracted string
|
||||||
|
if strings.Contains(ct, "text/html") {
|
||||||
|
extracted = htmlToText(rawText)
|
||||||
|
} else if strings.Contains(ct, "application/json") {
|
||||||
|
// Pretty-print JSON
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(body, &v) == nil {
|
||||||
|
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||||
|
extracted = string(pretty)
|
||||||
|
} else {
|
||||||
|
extracted = rawText
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extracted = rawText
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extracted = rawText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up and truncate
|
||||||
|
extracted = strings.TrimSpace(extracted)
|
||||||
|
if len(extracted) > maxChars {
|
||||||
|
extracted = extracted[:maxChars] + "\n\n[Content truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if extracted == "" {
|
||||||
|
extracted = "(empty content)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": extracted,
|
||||||
|
"details": map[string]interface{}{
|
||||||
|
"url": rawURL,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"content_type": ct,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML → 文本 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func htmlToText(html string) string {
|
||||||
|
// Remove scripts
|
||||||
|
for {
|
||||||
|
start := strings.Index(strings.ToLower(html), "<script")
|
||||||
|
if start < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.Index(html[start:], "</script>")
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
html = html[:start] + html[start+end+9:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove styles
|
||||||
|
for {
|
||||||
|
start := strings.Index(strings.ToLower(html), "<style")
|
||||||
|
if start < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.Index(html[start:], "</style>")
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
html = html[:start] + html[start+end+8:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace block-level tags with newlines
|
||||||
|
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
|
||||||
|
html = strings.ReplaceAll(html, tag, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove remaining tags
|
||||||
|
html = stripTags(html)
|
||||||
|
|
||||||
|
// Decode common entities
|
||||||
|
html = strings.ReplaceAll(html, "&", "&")
|
||||||
|
html = strings.ReplaceAll(html, "<", "<")
|
||||||
|
html = strings.ReplaceAll(html, ">", ">")
|
||||||
|
html = strings.ReplaceAll(html, """, "\"")
|
||||||
|
html = strings.ReplaceAll(html, "'", "'")
|
||||||
|
html = strings.ReplaceAll(html, " ", " ")
|
||||||
|
|
||||||
|
// Collapse whitespace
|
||||||
|
lines := strings.Split(html, "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Collapse internal whitespace
|
||||||
|
in := []rune(line)
|
||||||
|
var out []rune
|
||||||
|
space := false
|
||||||
|
for _, r := range in {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
if !space {
|
||||||
|
out = append(out, ' ')
|
||||||
|
space = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out = append(out, r)
|
||||||
|
space = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(cleaned, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripTags(s string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '<' {
|
||||||
|
inTag = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '>' {
|
||||||
|
inTag = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inTag {
|
||||||
|
out.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search 处理 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
if query == "" {
|
||||||
|
return errorResult("query is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 5
|
||||||
|
if v, ok := args["count"].(float64); ok && v > 0 {
|
||||||
|
count = int(v)
|
||||||
|
}
|
||||||
|
if count < 1 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
if count > 20 {
|
||||||
|
count = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := p.ddgSearch(query, count)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("search failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "No results found.",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
|
||||||
|
for i, r := range results {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": strings.TrimSpace(sb.String()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"isError": true,
|
||||||
|
"content": msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||||
|
v, err := s.Get(key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
val, ok := v.(T)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
7
example/webfetch/go.mod
Normal file
7
example/webfetch/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module webfetch
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
|
||||||
11
example/webfetch/plg.json
Normal file
11
example/webfetch/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "webfetch",
|
||||||
|
"name_zh": "网页抓取",
|
||||||
|
"name_en": "webfetch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "网页内容抓取工具,使用无头 Chromium 浏览器获取网页文字内容",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["web", "fetch"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
143
example/webfetch/plugin.go
Normal file
143
example/webfetch/plugin.go
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
|
||||||
|
s.RegisterTool("web_fetch", sdk.ToolDef{
|
||||||
|
Name: "web_fetch",
|
||||||
|
Description: "获取网页文字内容。使用无头 Chromium 浏览器渲染页面后提取正文文字,返回标题和前 5000 字符。适用于需要查看网页内容的场景。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "要访问的网页 URL"},
|
||||||
|
"wait": map[string]interface{}{"type": "integer", "description": "等待秒数(用于 JS 渲染页面,默认 0)"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleWebFetch)
|
||||||
|
|
||||||
|
log.Printf("[%s] plugin started", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
func convInt64(v interface{}) (int64, error) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(x), nil
|
||||||
|
case int64:
|
||||||
|
return x, nil
|
||||||
|
case json.Number:
|
||||||
|
return x.Int64()
|
||||||
|
case string:
|
||||||
|
return 0, fmt.Errorf("cannot convert string to int64")
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("cannot convert %T to int64", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleWebFetch(args map[string]interface{}) (interface{}, error) {
|
||||||
|
url, _ := args["url"].(string)
|
||||||
|
if url == "" {
|
||||||
|
return nil, fmt.Errorf("url is required")
|
||||||
|
}
|
||||||
|
waitSec, _ := convInt64(args["wait"])
|
||||||
|
|
||||||
|
if waitSec > 0 {
|
||||||
|
time.Sleep(time.Duration(waitSec) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
var html string
|
||||||
|
chromiumPath := "/usr/local/bin/chromium"
|
||||||
|
if _, err := os.Stat(chromiumPath); err == nil {
|
||||||
|
var out bytes.Buffer
|
||||||
|
argsList := []string{"--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", url}
|
||||||
|
cmd := exec.Command(chromiumPath, argsList...)
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = nil
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("chromium: %w", err)
|
||||||
|
}
|
||||||
|
html = out.String()
|
||||||
|
} else {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http get: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
html = string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ""
|
||||||
|
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
||||||
|
title = m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var textOut bytes.Buffer
|
||||||
|
pyCmd := exec.Command("python3", "-c", `
|
||||||
|
import sys, re, html
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
text = re.sub(r'<[^>]+>', ' ', raw)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
text = html.unescape(text)
|
||||||
|
sys.stdout.write(text)
|
||||||
|
`)
|
||||||
|
pyCmd.Stdin = strings.NewReader(html)
|
||||||
|
pyCmd.Stdout = &textOut
|
||||||
|
pyCmd.Stderr = nil
|
||||||
|
pyCmd.Run()
|
||||||
|
text := strings.TrimSpace(textOut.String())
|
||||||
|
|
||||||
|
origLen := len(text)
|
||||||
|
truncated := origLen > 5000
|
||||||
|
if truncated {
|
||||||
|
text = text[:5000]
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ""
|
||||||
|
if title != "" {
|
||||||
|
result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, url)
|
||||||
|
}
|
||||||
|
result += text
|
||||||
|
if truncated {
|
||||||
|
result += fmt.Sprintf("\n\n...(内容过长,仅显示前 5000 字符,共 %d 字符)", origLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": result,
|
||||||
|
"title": title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
23
meta/meta.go
Normal file
23
meta/meta.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
||||||
|
// 版本号应与核心 meta.Version 保持一致。
|
||||||
|
package meta
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Version 是 HomeAgent SDK 版本号。
|
||||||
|
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||||
|
Version = "0.7.1"
|
||||||
|
|
||||||
|
// Commit 是构建时的 Git commit hash。
|
||||||
|
Commit = "unknown"
|
||||||
|
|
||||||
|
// BuildTime 是构建时间。
|
||||||
|
BuildTime = "unknown"
|
||||||
|
|
||||||
|
// SDKName 是 SDK 名称。
|
||||||
|
SDKName = "HomeAgent SDK"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FullVersion 返回完整的版本字符串。
|
||||||
|
func FullVersion() string {
|
||||||
|
return SDKName + " v" + Version + " (" + Commit + ")"
|
||||||
|
}
|
||||||
14
sdk/knowledge.go
Normal file
14
sdk/knowledge.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
// KnowledgeAPI provides access to the knowledge store.
|
||||||
|
type KnowledgeAPI interface {
|
||||||
|
Search(query string, topK int) ([]*Knowledge, error)
|
||||||
|
Add(name, content string) error
|
||||||
|
List() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knowledge represents a knowledge entry.
|
||||||
|
type Knowledge struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
8
sdk/llm.go
Normal file
8
sdk/llm.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
// LLMAPI provides access to the LLM provider manager.
|
||||||
|
type LLMAPI interface {
|
||||||
|
ListSources() []string
|
||||||
|
SetSource(name string) error
|
||||||
|
CurrentSource() string
|
||||||
|
}
|
||||||
87
sdk/memory.go
Normal file
87
sdk/memory.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
// MemoryAPI provides access to the graph memory (entity-relation store).
|
||||||
|
type MemoryAPI interface {
|
||||||
|
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||||
|
Commit(triples []Triple) error
|
||||||
|
Introspect() (map[string]interface{}, error)
|
||||||
|
MergeEntities(source, target string) (int, error)
|
||||||
|
Purge(criteria map[string]string, mode string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity represents a named entity in the knowledge graph.
|
||||||
|
type Entity struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
MentionCount int `json:"mention_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relation represents a relationship between two entities.
|
||||||
|
type Relation struct {
|
||||||
|
SourceName string `json:"source_name"`
|
||||||
|
TargetName string `json:"target_name"`
|
||||||
|
RelationType string `json:"relation_type"`
|
||||||
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triple represents a subject-relation-object triple for the knowledge graph.
|
||||||
|
type Triple struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
|
SubjectType string `json:"subject_type,omitempty"`
|
||||||
|
ObjectType string `json:"object_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextMemoryAPI provides access to chronological text event storage.
|
||||||
|
type TextMemoryAPI interface {
|
||||||
|
Append(evt TextEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextEvent represents a single text memory event.
|
||||||
|
type TextEvent struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocMemoryAPI provides access to the document vector store.
|
||||||
|
type DocMemoryAPI interface {
|
||||||
|
Query(text string, topK int) []*Doc
|
||||||
|
Insert(doc *Doc) error
|
||||||
|
Remove(id string)
|
||||||
|
Stats() map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc represents a document in the document store.
|
||||||
|
type Doc struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||||
|
// External plugins can query person traits and social networks but cannot modify them.
|
||||||
|
type SocialAPI interface {
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonProfile represents a person's complete profile (traits + social relations).
|
||||||
|
type PersonProfile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Traits map[string]string `json:"traits,omitempty"`
|
||||||
|
Relations []SocialRelation `json:"relations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocialRelation represents a social relationship between two persons.
|
||||||
|
type SocialRelation struct {
|
||||||
|
Person string `json:"person"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
}
|
||||||
341
sdk/plugin.go
Normal file
341
sdk/plugin.go
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDKVersion 是对外暴露的 SDK 版本号。
|
||||||
|
var SDKVersion = meta.Version
|
||||||
|
|
||||||
|
// Plugin is the interface every plugin must implement.
|
||||||
|
type Plugin interface {
|
||||||
|
Name() string
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
Stop() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolHandler is a function that handles a tool call.
|
||||||
|
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||||
|
|
||||||
|
// StageHandler is a function that handles a pipeline stage event.
|
||||||
|
type StageHandler func(ctx *StageContext) error
|
||||||
|
|
||||||
|
// Stage represents a point in the message processing pipeline.
|
||||||
|
type Stage string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StageOnInput Stage = "on_input"
|
||||||
|
StagePreAction Stage = "pre_action"
|
||||||
|
StagePostAction Stage = "post_action"
|
||||||
|
StageBeforeToolcall Stage = "before_toolcall"
|
||||||
|
StageAfterToolcall Stage = "after_toolcall"
|
||||||
|
StageBeforeOutput Stage = "before_output"
|
||||||
|
StageAfterOutput Stage = "after_output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StageContext provides context for stage handlers.
|
||||||
|
type StageContext struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
RawMessage string
|
||||||
|
UserID string
|
||||||
|
GroupID string
|
||||||
|
ContextMsgs []map[string]interface{}
|
||||||
|
LLMText string
|
||||||
|
ReasoningContent string
|
||||||
|
TokenUsage map[string]int
|
||||||
|
ToolCalls []ToolCall
|
||||||
|
ToolResults []ToolResult
|
||||||
|
FinalText string
|
||||||
|
Response *string
|
||||||
|
Phase Stage
|
||||||
|
Memory []MemItem
|
||||||
|
NoMemory bool
|
||||||
|
Extra map[string]interface{}
|
||||||
|
Errors []string // 阶段处理过程中的错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *StageContext) RLock() { c.mu.RLock() }
|
||||||
|
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
||||||
|
func (c *StageContext) Lock() { c.mu.Lock() }
|
||||||
|
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
||||||
|
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
|
||||||
|
|
||||||
|
// MemItem represents a memory item in stage context.
|
||||||
|
type MemItem struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall represents a model's request to call a tool.
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Plugin string `json:"plugin,omitempty"`
|
||||||
|
Arguments map[string]interface{} `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolResult represents the result of a tool call.
|
||||||
|
type ToolResult struct {
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Plugin string `json:"plugin,omitempty"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result interface{} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolDef describes a tool that the plugin exposes.
|
||||||
|
type ToolDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Plugin string `json:"plugin,omitempty"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
|
// All methods accept (source, channel) where channel is the target output channel
|
||||||
|
// for routing the agent's response.
|
||||||
|
type IOInjector interface {
|
||||||
|
InjectInterruptText(source, channel, text string)
|
||||||
|
InjectText(source, channel, text string)
|
||||||
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventType identifies the kind of system event.
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventRawInput EventType = "raw_input"
|
||||||
|
EventAgentOutput EventType = "agent_output"
|
||||||
|
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||||
|
EventToolCall EventType = "tool_call"
|
||||||
|
EventReasoning EventType = "reasoning"
|
||||||
|
EventStage EventType = "stage"
|
||||||
|
EventSystem EventType = "system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents a system event published by the kernel.
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Payload map[string]interface{} `json:"payload"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventHandler processes a system event.
|
||||||
|
type EventHandler func(evt *Event)
|
||||||
|
|
||||||
|
// EventSubscriber allows plugins to subscribe to kernel events.
|
||||||
|
// This is a restricted interface: plugins can subscribe but the kernel
|
||||||
|
// controls which events are delivered.
|
||||||
|
type EventSubscriber interface {
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StageScope controls which events a stage handler receives.
|
||||||
|
type StageScope int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StageScopeGlobal receives all stage events (default).
|
||||||
|
StageScopeGlobal StageScope = 0
|
||||||
|
// StageScopeOwnTools only receives events for this plugin's own tool calls
|
||||||
|
// (before_toolcall / after_toolcall only). Other stages degrade to global.
|
||||||
|
StageScopeOwnTools StageScope = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolRegistrar registers a tool dynamically.
|
||||||
|
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
|
||||||
|
// StageRegistrar registers a stage handler.
|
||||||
|
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||||
|
|
||||||
|
// APIRegistrar registers a plugin API for external access.
|
||||||
|
type APIRegistrar func(name string) error
|
||||||
|
|
||||||
|
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||||
|
type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error
|
||||||
|
|
||||||
|
// Output capability flags
|
||||||
|
const (
|
||||||
|
CapText = 1
|
||||||
|
CapFile = 2
|
||||||
|
CapImage = 4
|
||||||
|
CapAudio = 8
|
||||||
|
CapStructured = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
io IOInjector
|
||||||
|
mem MemoryAPI
|
||||||
|
textMem TextMemoryAPI
|
||||||
|
docMem DocMemoryAPI
|
||||||
|
know KnowledgeAPI
|
||||||
|
llm LLMAPI
|
||||||
|
sett SettingsAPI
|
||||||
|
social SocialAPI
|
||||||
|
events EventSubscriber
|
||||||
|
|
||||||
|
autoRestart bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a PluginSDK with the given dependencies.
|
||||||
|
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK {
|
||||||
|
return &PluginSDK{
|
||||||
|
name: name,
|
||||||
|
sett: sett,
|
||||||
|
regTool: regTool,
|
||||||
|
regStage: regStage,
|
||||||
|
regAPI: regAPI,
|
||||||
|
regOutput: regOutput,
|
||||||
|
autoRestart: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginName returns the name of the plugin.
|
||||||
|
func (s *PluginSDK) PluginName() string { return s.name }
|
||||||
|
|
||||||
|
// Settings returns the settings API for reading/writing plugin configuration.
|
||||||
|
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
|
||||||
|
|
||||||
|
// Memory returns the graph memory API (may be nil if not available).
|
||||||
|
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
|
||||||
|
|
||||||
|
// TextMemory returns the text memory API (may be nil if not available).
|
||||||
|
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
||||||
|
|
||||||
|
// DocMemory returns the document memory API (may be nil if not available).
|
||||||
|
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
||||||
|
|
||||||
|
// Knowledge returns the knowledge store API (may be nil if not available).
|
||||||
|
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
|
||||||
|
|
||||||
|
// LLM returns the LLM provider API (may be nil if not available).
|
||||||
|
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
|
||||||
|
|
||||||
|
// Social returns the social graph API (may be nil if not available).
|
||||||
|
func (s *PluginSDK) Social() SocialAPI { return s.social }
|
||||||
|
|
||||||
|
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber { return s.events }
|
||||||
|
|
||||||
|
// RegisterTool registers a tool that the LLM can call.
|
||||||
|
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
|
||||||
|
if def.Plugin == "" {
|
||||||
|
def.Plugin = s.name
|
||||||
|
}
|
||||||
|
if s.regTool != nil {
|
||||||
|
return s.regTool(name, def, handler)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterStage registers a handler for a pipeline stage.
|
||||||
|
// scope: StageScopeGlobal (default) — receives all stage events.
|
||||||
|
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||||
|
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
|
||||||
|
if s.regStage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc := StageScopeGlobal
|
||||||
|
if len(scope) > 0 {
|
||||||
|
sc = scope[0]
|
||||||
|
}
|
||||||
|
if sc == StageScopeGlobal {
|
||||||
|
s.regStage(stage, handler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// OwnTools scope — only for before_toolcall / after_toolcall
|
||||||
|
if stage != StageBeforeToolcall && stage != StageAfterToolcall {
|
||||||
|
s.regStage(stage, handler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.regStage(stage, func(ctx *StageContext) error {
|
||||||
|
ctx.RLock()
|
||||||
|
match := false
|
||||||
|
switch stage {
|
||||||
|
case StageBeforeToolcall:
|
||||||
|
match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name
|
||||||
|
case StageAfterToolcall:
|
||||||
|
match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name
|
||||||
|
}
|
||||||
|
ctx.RUnlock()
|
||||||
|
if !match {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return handler(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterPluginAPI registers this plugin's API for access by other plugins.
|
||||||
|
func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||||
|
if s.regAPI != nil {
|
||||||
|
return s.regAPI(name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterOutputChannel registers an output channel that the output_send tool can route to.
|
||||||
|
// name: channel name (e.g. "qq", "webui")
|
||||||
|
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||||
|
// desc: description of the channel and expected JSON format for content
|
||||||
|
// handler: receives the content (JSON string) and returns result/error
|
||||||
|
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error {
|
||||||
|
if s.regOutput != nil {
|
||||||
|
return s.regOutput(name, caps, desc, handler)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||||
|
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
|
||||||
|
|
||||||
|
// SetIOInjector sets the IO injector (called by the core at startup).
|
||||||
|
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
|
||||||
|
|
||||||
|
// SetMemoryAPI sets the memory API (called by the core at startup).
|
||||||
|
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
|
||||||
|
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
|
||||||
|
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
|
||||||
|
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
|
||||||
|
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
||||||
|
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
||||||
|
|
||||||
|
// ---- IO Convenience Methods ----
|
||||||
|
|
||||||
|
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||||
|
if s.io != nil {
|
||||||
|
s.io.InjectInterruptText(source, channel, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectText injects a text message into the agent pipeline.
|
||||||
|
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||||
|
if s.io != nil {
|
||||||
|
s.io.InjectText(source, channel, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectTextNoMemory injects a text message without generating memory.
|
||||||
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||||
|
if s.io != nil {
|
||||||
|
s.io.InjectTextNoMemory(source, channel, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||||
|
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||||
|
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
|
||||||
|
|
||||||
|
// AutoRestart 返回插件是否允许自动重启。
|
||||||
|
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
|
||||||
179
sdk/plugin_test.go
Normal file
179
sdk/plugin_test.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterStageGlobalDefault(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
called = true
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "test"}
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil })
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Error("global scope: handler not registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageGlobalExplicit(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
called = true
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "test"}
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal)
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Error("global scope: handler not registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsMatch(t *testing.T) {
|
||||||
|
var registered StageHandler
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
registered = handler
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||||
|
|
||||||
|
if registered == nil {
|
||||||
|
t.Fatal("handler not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &StageContext{}
|
||||||
|
ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
|
||||||
|
ctx.ToolResults = nil
|
||||||
|
|
||||||
|
err := registered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
|
||||||
|
var registered StageHandler
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
registered = handler
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
|
||||||
|
callCount++
|
||||||
|
return nil
|
||||||
|
}, StageScopeOwnTools)
|
||||||
|
|
||||||
|
if registered == nil {
|
||||||
|
t.Fatal("handler not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &StageContext{}
|
||||||
|
ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}}
|
||||||
|
|
||||||
|
err := registered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 0 {
|
||||||
|
t.Error("handler should not be called for other plugin's tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) {
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
if stage != StagePreAction {
|
||||||
|
t.Errorf("expected StagePreAction, got %s", stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "test"}
|
||||||
|
s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) {
|
||||||
|
var registered StageHandler
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
registered = handler
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
|
||||||
|
callCount++
|
||||||
|
return nil
|
||||||
|
}, StageScopeOwnTools)
|
||||||
|
|
||||||
|
if registered == nil {
|
||||||
|
t.Fatal("handler not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &StageContext{}
|
||||||
|
|
||||||
|
err := registered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 0 {
|
||||||
|
t.Error("handler should not be called when ToolCalls is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
|
||||||
|
var registered StageHandler
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
registered = handler
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
|
||||||
|
callCount++
|
||||||
|
return nil
|
||||||
|
}, StageScopeOwnTools)
|
||||||
|
|
||||||
|
if registered == nil {
|
||||||
|
t.Fatal("handler not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &StageContext{}
|
||||||
|
ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
|
||||||
|
|
||||||
|
err := registered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Error("handler should be called for own plugin's tool result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
|
||||||
|
var registered StageHandler
|
||||||
|
regStage := func(stage Stage, handler StageHandler) {
|
||||||
|
registered = handler
|
||||||
|
}
|
||||||
|
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
|
||||||
|
callCount++
|
||||||
|
return nil
|
||||||
|
}, StageScopeOwnTools)
|
||||||
|
|
||||||
|
ctx := &StageContext{}
|
||||||
|
ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}}
|
||||||
|
|
||||||
|
err := registered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 0 {
|
||||||
|
t.Error("handler should not be called for other plugin's tool result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
|
||||||
|
s := &PluginSDK{name: "test"}
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||||
|
}
|
||||||
58
sdk/settings.go
Normal file
58
sdk/settings.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
type SettingsAPI interface {
|
||||||
|
// Get reads the plugin's own config value (config_<name> table).
|
||||||
|
Get(key string) (interface{}, error)
|
||||||
|
|
||||||
|
// Set writes a config value to the plugin's own config table.
|
||||||
|
Set(key string, value interface{}) error
|
||||||
|
|
||||||
|
// List returns all keys matching the given prefix.
|
||||||
|
List(prefix string) ([]string, error)
|
||||||
|
|
||||||
|
// GetCore reads the core config table.
|
||||||
|
GetCore(key string) (interface{}, error)
|
||||||
|
|
||||||
|
// SetCore writes to the core config table.
|
||||||
|
SetCore(key string, value interface{}) error
|
||||||
|
|
||||||
|
// ListCore lists core config keys matching the prefix.
|
||||||
|
ListCore(prefix string) ([]string, error)
|
||||||
|
|
||||||
|
// GetPlugin reads another plugin's config table.
|
||||||
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
|
||||||
|
// SetPlugin writes to another plugin's config table.
|
||||||
|
SetPlugin(plugin, key string, value interface{}) error
|
||||||
|
|
||||||
|
// ListPlugin lists another plugin's config keys matching the prefix.
|
||||||
|
ListPlugin(plugin, prefix string) ([]string, error)
|
||||||
|
|
||||||
|
// RegisterDef registers a config definition for UI display.
|
||||||
|
RegisterDef(def ConfigDef)
|
||||||
|
|
||||||
|
// Defs returns config definitions matching the prefix.
|
||||||
|
Defs(prefix string) []*ConfigDef
|
||||||
|
|
||||||
|
// Dump returns all config values.
|
||||||
|
Dump() map[string]interface{}
|
||||||
|
|
||||||
|
// Plugins returns a list of all plugin config namespaces.
|
||||||
|
Plugins() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigDef describes a configuration field for the WebUI.
|
||||||
|
type ConfigDef struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Min float64 `json:"min,omitempty"`
|
||||||
|
Max float64 `json:"max,omitempty"`
|
||||||
|
Step float64 `json:"step,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
}
|
||||||
415
tools/plugindev/cmd_build.go
Normal file
415
tools/plugindev/cmd_build.go
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildConfig struct {
|
||||||
|
OutDir string
|
||||||
|
Targets []string // "linux/amd64", "windows/amd64", "lua"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdBuild(args []string) {
|
||||||
|
cfg := BuildConfig{OutDir: "dist"}
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--outdir":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
cfg.OutDir = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--target":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
cfg.Targets = append(cfg.Targets, args[i+1])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read plg.json
|
||||||
|
plg, err := readPlgJSON("plg.json")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: read plg.json: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine targets
|
||||||
|
targets := cfg.Targets
|
||||||
|
if len(targets) == 0 {
|
||||||
|
targets = parseTargets(plg.Targets)
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
targets = []string{"native"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build for each target
|
||||||
|
for _, t := range targets {
|
||||||
|
buildTarget(plg, t, cfg.OutDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPlgJSON(path string) (*PlgConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var plg PlgConfig
|
||||||
|
if err := json.Unmarshal(data, &plg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &plg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTargets(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "native" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var t []string
|
||||||
|
for _, s := range strings.Split(raw, ",") {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s != "" {
|
||||||
|
t = append(t, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePluginJSON(plg *PlgConfig, entry string) {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"name": plg.Name,
|
||||||
|
"name_zh": plg.NameZh,
|
||||||
|
"name_en": plg.NameEn,
|
||||||
|
"version": plg.Version,
|
||||||
|
"description": plg.Description,
|
||||||
|
"author": plg.Author,
|
||||||
|
"entry": entry,
|
||||||
|
}
|
||||||
|
if len(plg.Tags) > 0 {
|
||||||
|
m["tags"] = plg.Tags
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(m, "", " ")
|
||||||
|
os.WriteFile("plugin.json", data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
type buildConfig struct {
|
||||||
|
goos string
|
||||||
|
goarch string
|
||||||
|
entryFile string // "plugin.so" or "plugin.dll"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBuild(target string) (*buildConfig, string) {
|
||||||
|
if target == "lua" || target == "" {
|
||||||
|
return nil, "lua"
|
||||||
|
}
|
||||||
|
|
||||||
|
goos, goarch, _ := strings.Cut(target, "/")
|
||||||
|
if goos == "" {
|
||||||
|
goos = runtime.GOOS
|
||||||
|
if goarch == "" {
|
||||||
|
goarch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch goos {
|
||||||
|
case "linux", "darwin", "freebsd", "windows":
|
||||||
|
ext := ".so"
|
||||||
|
if goos == "windows" {
|
||||||
|
ext = ".dll"
|
||||||
|
}
|
||||||
|
return &buildConfig{
|
||||||
|
goos: goos,
|
||||||
|
goarch: goarch,
|
||||||
|
entryFile: "plugin" + ext,
|
||||||
|
}, ""
|
||||||
|
default:
|
||||||
|
return nil, fmt.Sprintf("unsupported OS %q", goos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTarget(plg *PlgConfig, target, outDir string) {
|
||||||
|
os.MkdirAll(outDir, 0755)
|
||||||
|
|
||||||
|
// Lua: no compilation, package source directly
|
||||||
|
if target == "lua" {
|
||||||
|
writePluginJSON(plg, "main.lua")
|
||||||
|
pkgFiles := []string{"plugin.json", "main.lua"}
|
||||||
|
for _, f := range []string{"README.md", "LICENSE"} {
|
||||||
|
if _, err := os.Stat(f); err == nil {
|
||||||
|
pkgFiles = append(pkgFiles, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Include thirdpart Lua files
|
||||||
|
if entries, err := os.ReadDir("thirdpart"); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
|
||||||
|
path := filepath.Join("thirdpart", e.Name())
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
pkgFiles = append(pkgFiles, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hmapName := fmt.Sprintf("%s_lua.hmap", toSnake(plg.NameEn))
|
||||||
|
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
|
||||||
|
fmt.Printf(" packaged %s\n", hmapName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve build config
|
||||||
|
cfg, errMsg := resolveBuild(target)
|
||||||
|
if cfg == nil {
|
||||||
|
fmt.Printf(" error: %s\n", errMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDir := "build"
|
||||||
|
os.MkdirAll(buildDir, 0755)
|
||||||
|
outPath := filepath.Join(buildDir, cfg.entryFile)
|
||||||
|
|
||||||
|
// Auto-generate C ABI bridge (all platforms use c-shared)
|
||||||
|
bridgeCleanup := generateBridge(cfg.goos)
|
||||||
|
_ = bridgeCleanup // DISABLED cleanup for debug
|
||||||
|
|
||||||
|
// Auto-link thirdpart/ contents
|
||||||
|
thirdpartCleanup := linkThirdpart(target)
|
||||||
|
defer thirdpartCleanup()
|
||||||
|
|
||||||
|
// Write plugin.json with the correct entry for this target
|
||||||
|
writePluginJSON(plg, cfg.entryFile)
|
||||||
|
|
||||||
|
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath)
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
|
||||||
|
|
||||||
|
// Auto-detect MinGW gcc on Windows
|
||||||
|
if cfg.goos == "windows" {
|
||||||
|
cc := detectWindowsCC()
|
||||||
|
if cc != "" {
|
||||||
|
cmd.Env = append(cmd.Env, "CC="+cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
// DEBUG: list files before building
|
||||||
|
entries, _ := os.ReadDir(".")
|
||||||
|
for _, e := range entries {
|
||||||
|
fmt.Printf(" [DEBUG] file: %s\n", e.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// package
|
||||||
|
pkgFiles := []string{"plugin.json", outPath}
|
||||||
|
for _, f := range []string{"README.md", "LICENSE"} {
|
||||||
|
if _, err := os.Stat(f); err == nil {
|
||||||
|
pkgFiles = append(pkgFiles, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch)
|
||||||
|
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
|
||||||
|
fmt.Printf(" packaged %s\n", hmapName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createHmap(hmapPath string, files []string) {
|
||||||
|
f, err := os.Create(hmapPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := zip.NewWriter(f)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
for _, path := range files {
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hdr, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hdr.Method = zip.Deflate
|
||||||
|
hdr.Name = filepath.Base(path)
|
||||||
|
writer, err := w.CreateHeader(hdr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(writer, src)
|
||||||
|
src.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSnake(s string) string {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectWindowsCC looks for a MinGW-w64 gcc on Windows for c-shared builds.
|
||||||
|
func detectWindowsCC() string {
|
||||||
|
// Check CC from environment first
|
||||||
|
if cc := os.Getenv("CC"); cc != "" {
|
||||||
|
if _, err := exec.LookPath(cc); err == nil {
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check common MinGW install paths
|
||||||
|
candidates := []string{
|
||||||
|
"C:\\mingw64\\bin\\gcc.exe",
|
||||||
|
"C:\\MinGW\\bin\\gcc.exe",
|
||||||
|
"C:\\msys64\\mingw64\\bin\\gcc.exe",
|
||||||
|
"C:\\Users\\21989\\AppData\\Local\\Temp\\mingw64\\mingw64\\bin\\gcc.exe",
|
||||||
|
}
|
||||||
|
// Also search PATH for gcc
|
||||||
|
if path, err := exec.LookPath("gcc"); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
|
||||||
|
// since these can confuse cgo's type resolution.
|
||||||
|
func stripIncludeGuard(header string) string {
|
||||||
|
lines := strings.Split(header, "\n")
|
||||||
|
var out []string
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "#ifndef HOMEAGENT_CABI_H" || trimmed == "#define HOMEAGENT_CABI_H" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed == "#endif" || strings.HasPrefix(trimmed, "#endif") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed == "#ifdef __cplusplus" || trimmed == "extern \"C\" {" || trimmed == "}" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strip C++-style comments (cgo parser may not handle them in /* */ blocks)
|
||||||
|
if idx := strings.Index(line, "//"); idx >= 0 {
|
||||||
|
line = line[:idx]
|
||||||
|
}
|
||||||
|
cleaned := strings.TrimSpace(line)
|
||||||
|
if cleaned == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateBridge generates the C ABI bridge files for non-Lua builds.
|
||||||
|
// Returns a cleanup function to remove generated files.
|
||||||
|
func generateBridge(goos string) func() {
|
||||||
|
const bridgeFile = "z_bridge_gen.go"
|
||||||
|
const cEntryFile = "z_entry.c"
|
||||||
|
os.Remove(bridgeFile)
|
||||||
|
os.Remove(cEntryFile)
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
|
||||||
|
if goos == "windows" {
|
||||||
|
if err := os.WriteFile(bridgeFile, []byte(tmplBridge), 0644); err != nil {
|
||||||
|
fmt.Printf(" error: write bridge: %v\n", err)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
files = append(files, bridgeFile)
|
||||||
|
} else {
|
||||||
|
if err := os.WriteFile(bridgeFile, []byte(tmplLinuxBridge), 0644); err != nil {
|
||||||
|
fmt.Printf(" error: write bridge: %v\n", err)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
files = append(files, bridgeFile)
|
||||||
|
// Write C entry point file
|
||||||
|
if err := os.WriteFile(cEntryFile, []byte(tmplPluginInitC), 0644); err != nil {
|
||||||
|
fmt.Printf(" error: write C entry: %v\n", err)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
files = append(files, cEntryFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
for _, f := range files {
|
||||||
|
os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkThirdpart scans thirdpart/ for source files and generates auto-import stubs.
|
||||||
|
// For Go plugins: if thirdpart/*.go exists, generate z_thirdpart.go with import.
|
||||||
|
// For Lua plugins: no action needed (thirdpart/*.lua is packaged separately in buildTarget).
|
||||||
|
// Returns cleanup function to remove generated files.
|
||||||
|
func linkThirdpart(target string) func() {
|
||||||
|
const thirdpartDir = "thirdpart"
|
||||||
|
const importFile = "z_thirdpart.go"
|
||||||
|
os.Remove(importFile)
|
||||||
|
|
||||||
|
if info, err := os.Stat(thirdpartDir); err != nil || !info.IsDir() {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(thirdpartDir)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Go builds: check for .go files
|
||||||
|
if target != "lua" {
|
||||||
|
hasGo := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
|
||||||
|
hasGo = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasGo {
|
||||||
|
// Read go.mod to get the module path
|
||||||
|
gomodPath := "go.mod"
|
||||||
|
data, err := os.ReadFile(gomodPath)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
modulePath := ""
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "module ") {
|
||||||
|
modulePath = strings.TrimSpace(line[7:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modulePath != "" {
|
||||||
|
importPath := modulePath + "/" + thirdpartDir
|
||||||
|
stub := "package main\nimport _ \"" + importPath + "\"\n"
|
||||||
|
os.WriteFile(importFile, []byte(stub), 0644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
os.Remove(importFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tools/plugindev/cmd_clean.go
Normal file
33
tools/plugindev/cmd_clean.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdClean(args []string) {
|
||||||
|
outDir := "dist"
|
||||||
|
if len(args) > 0 && args[0] == "--outdir" && len(args) > 1 {
|
||||||
|
outDir = args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs := []string{"build", outDir}
|
||||||
|
for _, d := range dirs {
|
||||||
|
if _, err := os.Stat(d); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(d); err != nil {
|
||||||
|
fmt.Printf("error: remove %s: %v\n", d, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("removed %s/\n", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// also clean generated files
|
||||||
|
for _, f := range []string{"plugin.json", "z_bridge_gen.go"} {
|
||||||
|
if _, err := os.Stat(f); err == nil {
|
||||||
|
os.Remove(f)
|
||||||
|
fmt.Printf("removed %s\n", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tools/plugindev/cmd_debug.go
Normal file
134
tools/plugindev/cmd_debug.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tmplLuaDebug is the temporary Lua debug script template
|
||||||
|
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
||||||
|
-- Generated by plugindev debug --lua
|
||||||
|
sdk = require("sdk")
|
||||||
|
local ok, plugin = pcall(dofile, "main.lua")
|
||||||
|
if not ok then
|
||||||
|
print("[debug] ERROR loading main.lua: " .. tostring(plugin))
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
if type(plugin) == "table" then
|
||||||
|
print("[debug] Plugin: " .. tostring(plugin.name or "unnamed"))
|
||||||
|
if plugin.start then
|
||||||
|
print("[debug] Calling plugin.start(sdk) ...")
|
||||||
|
local ok, err = pcall(plugin.start, sdk)
|
||||||
|
if ok then
|
||||||
|
print("[debug] plugin.start() OK")
|
||||||
|
else
|
||||||
|
print("[debug] plugin:start() ERROR: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print("")
|
||||||
|
print("=== Interactive REPL ===")
|
||||||
|
print("sdk, plugin globals are available")
|
||||||
|
local function repl()
|
||||||
|
while true do
|
||||||
|
io.write("> ")
|
||||||
|
io.flush()
|
||||||
|
local line = io.read()
|
||||||
|
if line == nil or line == "exit" or line == "quit" then break end
|
||||||
|
if line == "help" then
|
||||||
|
print(" help - this help")
|
||||||
|
print(" exit/quit - exit debug")
|
||||||
|
print(" sdk - SDK API table")
|
||||||
|
print(" plugin - loaded plugin table")
|
||||||
|
else
|
||||||
|
local fn, err = (loadstring or load)(line)
|
||||||
|
if fn then
|
||||||
|
local ok, result = pcall(fn)
|
||||||
|
if ok and result ~= nil then print(tostring(result)) end
|
||||||
|
if not ok then print("Error: " .. tostring(result)) end
|
||||||
|
else
|
||||||
|
print("Error: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
repl()
|
||||||
|
`
|
||||||
|
|
||||||
|
func cmdDebug(args []string) {
|
||||||
|
dir := "."
|
||||||
|
if len(args) > 0 && args[0] != "" {
|
||||||
|
dir = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
luaPath := filepath.Join(dir, "main.lua")
|
||||||
|
goPath := filepath.Join(dir, "main.go")
|
||||||
|
sdkPath := filepath.Join(dir, "sdk.lua")
|
||||||
|
|
||||||
|
if _, err := os.Stat(luaPath); err == nil {
|
||||||
|
debugLua(dir, sdkPath, luaPath)
|
||||||
|
} else if _, err := os.Stat(goPath); err == nil {
|
||||||
|
debugGo(dir, goPath)
|
||||||
|
} else {
|
||||||
|
fmt.Println("error: no main.lua or main.go found in", dir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugLua(dir, sdkPath, luaPath string) {
|
||||||
|
// check lua interpreter
|
||||||
|
luaBin, err := exec.LookPath("lua")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error: lua interpreter not found in PATH")
|
||||||
|
fmt.Println(" install Lua 5.1+ or use plugindev build to compile your plugin")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[debug] Lua interpreter: %s\n", luaBin)
|
||||||
|
fmt.Printf("[debug] Plugin dir: %s\n", dir)
|
||||||
|
|
||||||
|
// check if sdk.lua exists
|
||||||
|
if _, err := os.Stat(sdkPath); os.IsNotExist(err) {
|
||||||
|
fmt.Println("warning: sdk.lua not found, debug SDK mock will not be available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// write temporary debug script
|
||||||
|
debugScript := filepath.Join(dir, "_debug.lua")
|
||||||
|
if err := os.WriteFile(debugScript, []byte(tmplLuaDebug), 0644); err != nil {
|
||||||
|
fmt.Printf("error: write debug script: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer os.Remove(debugScript)
|
||||||
|
|
||||||
|
cmd := exec.Command(luaBin, filepath.Base(debugScript))
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
fmt.Println("[debug] Starting Lua debug session...")
|
||||||
|
fmt.Println()
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
os.Exit(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
fmt.Printf("[debug] error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugGo(dir, goPath string) {
|
||||||
|
fmt.Println("Go debug mode: use standard Go tooling")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" go test -v ./... # run tests")
|
||||||
|
fmt.Println(" go build -o plugin.so -buildmode=plugin . # build plugin")
|
||||||
|
fmt.Println(" plugindev build # package as .hmap")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("For interactive Go debugging, use your IDE or dlv:")
|
||||||
|
fmt.Println(" dlv debug # Delve debugger")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = strings.TrimSpace
|
||||||
201
tools/plugindev/cmd_init.go
Normal file
201
tools/plugindev/cmd_init.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sdkRoot is the HomeAgent SDK root directory, computed at init time from source location.
|
||||||
|
var sdkRoot string
|
||||||
|
|
||||||
|
const cabiVersion = 1
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, filename, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// filename: <sdk_root>/tools/plugindev/cmd_init.go
|
||||||
|
sdkRoot = filepath.Dir(filepath.Dir(filepath.Dir(filename)))
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlgConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
NameZh string `json:"name_zh"`
|
||||||
|
NameEn string `json:"name_en"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Entry string `json:"entry"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Targets string `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateData struct {
|
||||||
|
Plg PlgConfig
|
||||||
|
IsLua bool
|
||||||
|
|
||||||
|
// Go module info (for go.mod)
|
||||||
|
ModulePath string
|
||||||
|
GoVersion string
|
||||||
|
SDKModule string
|
||||||
|
SDKVersion string
|
||||||
|
SDKReplace string
|
||||||
|
|
||||||
|
// C ABI
|
||||||
|
CABIVersion int
|
||||||
|
CABIHeader string
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdInit(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Println("Usage: plugindev init <name> [--lua]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
isLua := false
|
||||||
|
for _, a := range args[1:] {
|
||||||
|
switch a {
|
||||||
|
case "--lua":
|
||||||
|
isLua = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := name
|
||||||
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
|
fmt.Printf("error: directory %q already exists\n", dir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := "plugin.so"
|
||||||
|
var targets string
|
||||||
|
if isLua {
|
||||||
|
entry = "main.lua"
|
||||||
|
targets = "lua"
|
||||||
|
} else {
|
||||||
|
targets = "linux/amd64,windows/amd64"
|
||||||
|
}
|
||||||
|
|
||||||
|
nameEn := strings.ReplaceAll(name, "-", " ")
|
||||||
|
nameEn = strings.Title(nameEn)
|
||||||
|
|
||||||
|
data := TemplateData{
|
||||||
|
Plg: PlgConfig{
|
||||||
|
Name: name,
|
||||||
|
NameZh: "中文名",
|
||||||
|
NameEn: nameEn,
|
||||||
|
Version: "0.1.0",
|
||||||
|
Description: name + " plugin",
|
||||||
|
Author: "HomeAgent",
|
||||||
|
Entry: entry,
|
||||||
|
Tags: []string{name},
|
||||||
|
Targets: targets,
|
||||||
|
},
|
||||||
|
IsLua: isLua,
|
||||||
|
CABIVersion: cabiVersion,
|
||||||
|
CABIHeader: tmplCABIHeader,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect SDK info for Go plugin go.mod
|
||||||
|
if !isLua {
|
||||||
|
sdkMod, goVer, sdkPath := detectSDKInfo()
|
||||||
|
sdkReplace := sdkPath
|
||||||
|
// Make replace path absolute and use forward slashes
|
||||||
|
if abs, err := filepath.Abs(sdkPath); err == nil {
|
||||||
|
sdkReplace = strings.ReplaceAll(abs, "\\", "/")
|
||||||
|
}
|
||||||
|
data.ModulePath = name
|
||||||
|
data.GoVersion = goVer
|
||||||
|
data.SDKModule = sdkMod
|
||||||
|
data.SDKVersion = "v0.0.0"
|
||||||
|
data.SDKReplace = sdkReplace
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
fmt.Printf("error: create dir: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write plg.json
|
||||||
|
writeTemplate(filepath.Join(dir, "plg.json"), tmplPlgJSON, data)
|
||||||
|
|
||||||
|
// Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only
|
||||||
|
if isLua {
|
||||||
|
writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data)
|
||||||
|
writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data)
|
||||||
|
} else {
|
||||||
|
writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write README.md
|
||||||
|
writeTemplate(filepath.Join(dir, "README.md"), tmplReadme, data)
|
||||||
|
|
||||||
|
// write go.mod for Go plugins
|
||||||
|
if !isLua {
|
||||||
|
writeTemplate(filepath.Join(dir, "go.mod"), tmplGoMod, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create thirdpart directory for external library sources
|
||||||
|
os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755)
|
||||||
|
|
||||||
|
fmt.Printf("Created plugin project %q (%s)\n", dir, entry)
|
||||||
|
if isLua {
|
||||||
|
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
||||||
|
}
|
||||||
|
fmt.Printf(" cd %s && plugindev build\n", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSDKInfo reads the HomeAgent SDK's go.mod to get module path and go version.
|
||||||
|
func detectSDKInfo() (modulePath, goVersion, sdkPath string) {
|
||||||
|
if sdkRoot == "" {
|
||||||
|
fmt.Printf("error: cannot detect SDK root (built outside SDK tree?)\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
gomodPath := filepath.Join(sdkRoot, "go.mod")
|
||||||
|
data, err := os.ReadFile(gomodPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: cannot read SDK go.mod at %s: %v\n", gomodPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "module ") {
|
||||||
|
modulePath = strings.TrimSpace(line[7:])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "go ") {
|
||||||
|
goVersion = strings.TrimSpace(line[3:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modulePath == "" {
|
||||||
|
fmt.Printf("error: no module directive in %s\n", gomodPath)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if goVersion == "" {
|
||||||
|
goVersion = "1.21"
|
||||||
|
}
|
||||||
|
return modulePath, goVersion, sdkRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTemplate(path, content string, data TemplateData) {
|
||||||
|
tmpl, err := template.New("").Parse(content)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: parse template: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: create %s: %v\n", path, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := tmpl.Execute(f, data); err != nil {
|
||||||
|
fmt.Printf("error: execute template: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
tools/plugindev/go.mod
Normal file
3
tools/plugindev/go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module github.com/JianFeeeee/homeagent-sdk/tools/plugindev
|
||||||
|
|
||||||
|
go 1.21
|
||||||
41
tools/plugindev/main.go
Normal file
41
tools/plugindev/main.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
help()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "init":
|
||||||
|
cmdInit(os.Args[2:])
|
||||||
|
case "build":
|
||||||
|
cmdBuild(os.Args[2:])
|
||||||
|
case "clean":
|
||||||
|
cmdClean(os.Args[2:])
|
||||||
|
case "debug":
|
||||||
|
cmdDebug(os.Args[2:])
|
||||||
|
default:
|
||||||
|
help()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func help() {
|
||||||
|
fmt.Println(`HomeAgent Plugin Dev Tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
plugindev init <name> Scaffold a new plugin project
|
||||||
|
plugindev build [flags] Compile and package plugin
|
||||||
|
plugindev clean Clean build/dist artifacts
|
||||||
|
plugindev debug [dir] Interpret and debug plugin source
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--outdir Output directory (default: dist)
|
||||||
|
--target Target OS/arch (e.g. linux/amd64), repeatable
|
||||||
|
--lua Create Lua plugin (for init)
|
||||||
|
`)
|
||||||
|
}
|
||||||
3
tools/plugindev/template_c.go
Normal file
3
tools/plugindev/template_c.go
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// tmplPluginInitC is in templates.go (moved to keep all C ABI together)
|
||||||
727
tools/plugindev/templates.go
Normal file
727
tools/plugindev/templates.go
Normal file
@ -0,0 +1,727 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// tmplPlgJSON is the plg.json template
|
||||||
|
const tmplPlgJSON = `{
|
||||||
|
"name": "{{.Plg.Name}}",
|
||||||
|
"name_zh": "{{.Plg.NameZh}}",
|
||||||
|
"name_en": "{{.Plg.NameEn}}",
|
||||||
|
"version": "{{.Plg.Version}}",
|
||||||
|
"description": "{{.Plg.Description}}",
|
||||||
|
"author": "{{.Plg.Author}}",
|
||||||
|
"entry": "{{.Plg.Entry}}",
|
||||||
|
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
|
||||||
|
"targets": "{{.Plg.Targets}}"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplGoMod = `module {{.ModulePath}}
|
||||||
|
|
||||||
|
go {{.GoVersion}}
|
||||||
|
|
||||||
|
require {{.SDKModule}} {{.SDKVersion}}
|
||||||
|
|
||||||
|
replace {{.SDKModule}} => {{.SDKReplace}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplPluginGo = `package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
|
||||||
|
DisplayName: "示例配置", Description: "An example configuration key",
|
||||||
|
Category: "{{.Plg.Name}}",
|
||||||
|
})
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello", Description: "A hello world tool",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
}, p.handleHello)
|
||||||
|
fmt.Printf("[%s] started\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||||
|
sdk = {}
|
||||||
|
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
||||||
|
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
||||||
|
function sdk.register_stage(stage, handler) print("[lua-plugin] register_stage: " .. tostring(stage)) end
|
||||||
|
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
||||||
|
function sdk.get_setting(key) return nil end
|
||||||
|
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
||||||
|
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
||||||
|
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
||||||
|
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
||||||
|
sdk.json = {}
|
||||||
|
function sdk.json.encode(val)
|
||||||
|
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||||
|
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
||||||
|
elseif type(val) == "table" then local parts, i = {}, 1
|
||||||
|
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}" end
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||||
|
sdk.http = {}
|
||||||
|
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||||
|
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||||
|
return sdk
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplMainLua = `-- {{.Plg.Name}} plugin
|
||||||
|
local plugin = { name = "{{.Plg.Name}}" }
|
||||||
|
function plugin.start(sdk)
|
||||||
|
sdk.log("info", "{{.Plg.Name}} starting...")
|
||||||
|
sdk.register_tool("{{.Plg.Name}}_hello", {
|
||||||
|
description = "A hello world tool",
|
||||||
|
parameters = { type = "object", properties = {} }
|
||||||
|
}, function(args) return { content = "Hello from {{.Plg.Name}} plugin!" } end)
|
||||||
|
sdk.log("info", "{{.Plg.Name}} started")
|
||||||
|
end
|
||||||
|
function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end
|
||||||
|
return plugin
|
||||||
|
`
|
||||||
|
|
||||||
|
// tmplBridge — Windows DLL C ABI bridge (unchanged)
|
||||||
|
const tmplBridge = `//go:build windows && cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdlib.h>
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
handleMap = map[unsafe.Pointer]*bridgeState{}
|
||||||
|
)
|
||||||
|
|
||||||
|
type bridgeState struct {
|
||||||
|
plugin sdk.Plugin
|
||||||
|
toolDefs map[string]sdk.ToolDef
|
||||||
|
handlers map[string]sdk.ToolHandler
|
||||||
|
stages map[string]sdk.StageHandler
|
||||||
|
settings map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandle(plg sdk.Plugin) unsafe.Pointer {
|
||||||
|
mu.Lock(); defer mu.Unlock()
|
||||||
|
h := C.malloc(C.size_t(1))
|
||||||
|
handleMap[h] = &bridgeState{
|
||||||
|
plugin: plg, toolDefs: make(map[string]sdk.ToolDef),
|
||||||
|
handlers: make(map[string]sdk.ToolHandler), stages: make(map[string]sdk.StageHandler),
|
||||||
|
settings: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
func getState(h unsafe.Pointer) *bridgeState { mu.Lock(); defer mu.Unlock(); return handleMap[h] }
|
||||||
|
func delState(h unsafe.Pointer) { mu.Lock(); defer mu.Unlock(); delete(handleMap, h); C.free(h) }
|
||||||
|
|
||||||
|
//export NewPlugin
|
||||||
|
func NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer {
|
||||||
|
goName := C.GoString(name)
|
||||||
|
var config map[string]interface{}
|
||||||
|
if configJSON != nil {
|
||||||
|
var wrapper map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(C.GoString(configJSON)), &wrapper); err == nil {
|
||||||
|
if c, ok := wrapper["config"].(map[string]interface{}); ok { config = c }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plg, err := NewPlugin(goName, config)
|
||||||
|
if err != nil { return nil }
|
||||||
|
return newHandle(plg)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export StartPlugin
|
||||||
|
func StartPlugin(handle unsafe.Pointer) C.int {
|
||||||
|
bs := getState(handle)
|
||||||
|
if bs == nil { return 1 }
|
||||||
|
mockSett := &bridgeSettings{data: bs.settings}
|
||||||
|
mockSDK := sdk.New(bs.plugin.Name(), mockSett,
|
||||||
|
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
bs.toolDefs[name] = def; bs.handlers[name] = handler; return nil
|
||||||
|
},
|
||||||
|
func(stage sdk.Stage, handler sdk.StageHandler) { bs.stages[string(stage)] = handler },
|
||||||
|
func(name string) error { return nil },
|
||||||
|
)
|
||||||
|
if err := bs.plugin.Start(mockSDK); err != nil { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export StopPlugin
|
||||||
|
func StopPlugin(handle unsafe.Pointer) C.int {
|
||||||
|
bs := getState(handle)
|
||||||
|
if bs == nil { return 1 }
|
||||||
|
if err := bs.plugin.Stop(); err != nil { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export DestroyPlugin
|
||||||
|
func DestroyPlugin(handle unsafe.Pointer) {
|
||||||
|
if bs := getState(handle); bs != nil { delState(handle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
//export GetToolDefsJSON
|
||||||
|
func GetToolDefsJSON(handle unsafe.Pointer) *C.char {
|
||||||
|
bs := getState(handle)
|
||||||
|
if bs == nil { return nil }
|
||||||
|
defs := make([]sdk.ToolDef, 0, len(bs.toolDefs))
|
||||||
|
for _, def := range bs.toolDefs { defs = append(defs, def) }
|
||||||
|
b, _ := json.Marshal(defs)
|
||||||
|
return C.CString(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
//export InvokeToolJSON
|
||||||
|
func InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char {
|
||||||
|
bs := getState(handle)
|
||||||
|
if bs == nil || toolName == nil { return nil }
|
||||||
|
goName := C.GoString(toolName)
|
||||||
|
handler, ok := bs.handlers[goName]
|
||||||
|
if !ok { r, _ := json.Marshal(map[string]interface{}{"error": "tool not found: " + goName}); return C.CString(string(r)) }
|
||||||
|
var args map[string]interface{}
|
||||||
|
if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) }
|
||||||
|
r, err := handler(args)
|
||||||
|
if err != nil { r, _ = json.Marshal(map[string]interface{}{"error": err.Error()}); return C.CString(string(r)) }
|
||||||
|
b, _ := json.Marshal(r)
|
||||||
|
return C.CString(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
//export GetStagesJSON
|
||||||
|
func GetStagesJSON(handle unsafe.Pointer) *C.char {
|
||||||
|
bs := getState(handle)
|
||||||
|
if bs == nil { return nil }
|
||||||
|
type se struct { Stage string ` + "`" + `json:"stage"` + "`" + ` }
|
||||||
|
var entries []se
|
||||||
|
for s := range bs.stages { entries = append(entries, se{s}) }
|
||||||
|
b, _ := json.Marshal(entries)
|
||||||
|
return C.CString(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
//export InvokeStage
|
||||||
|
func InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int {
|
||||||
|
bs := getState(handle)
|
||||||
|
if bs == nil || stage == nil { return 1 }
|
||||||
|
goStage := C.GoString(stage)
|
||||||
|
handler, ok := bs.stages[goStage]
|
||||||
|
if !ok { return 1 }
|
||||||
|
var ctx map[string]interface{}
|
||||||
|
if contextJSON != nil { json.Unmarshal([]byte(C.GoString(contextJSON)), &ctx) }
|
||||||
|
sc := &sdk.StageContext{}
|
||||||
|
if ctx != nil {
|
||||||
|
if v, ok := ctx["raw_message"].(string); ok { sc.RawMessage = v }
|
||||||
|
if v, ok := ctx["user_id"].(string); ok { sc.UserID = v }
|
||||||
|
if v, ok := ctx["phase"].(string); ok { sc.Phase = sdk.Stage(v) }
|
||||||
|
}
|
||||||
|
if err := handler(sc); err != nil { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export FreeCString
|
||||||
|
func FreeCString(s *C.char) { C.free(unsafe.Pointer(s)) }
|
||||||
|
|
||||||
|
type bridgeSettings struct{ data map[string]interface{} }
|
||||||
|
func (s *bridgeSettings) Get(key string) (interface{}, error) { v, ok := s.data[key]; if !ok { return nil, nil }; return v, nil }
|
||||||
|
func (s *bridgeSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
|
||||||
|
func (s *bridgeSettings) List(prefix string) ([]string, error) {
|
||||||
|
var keys []string
|
||||||
|
for k := range s.data { if len(k) >= len(prefix) && k[:len(prefix)] == prefix { keys = append(keys, k) } }
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
func (s *bridgeSettings) GetCore(key string) (interface{}, error) { return nil, nil }
|
||||||
|
func (s *bridgeSettings) SetCore(key string, value interface{}) error { return nil }
|
||||||
|
func (s *bridgeSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
|
||||||
|
func (s *bridgeSettings) GetPlugin(plugin, key string) (interface{}, error) { return nil, nil }
|
||||||
|
func (s *bridgeSettings) SetPlugin(plugin, key string, value interface{}) error { return nil }
|
||||||
|
func (s *bridgeSettings) ListPlugin(plugin, prefix string) ([]string, error) { return nil, nil }
|
||||||
|
func (s *bridgeSettings) RegisterDef(def sdk.ConfigDef) {}
|
||||||
|
func (s *bridgeSettings) Defs(prefix string) []*sdk.ConfigDef { return nil }
|
||||||
|
func (s *bridgeSettings) Dump() map[string]interface{} { return s.data }
|
||||||
|
func (s *bridgeSettings) Plugins() []string { return nil }
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
`
|
||||||
|
|
||||||
|
// tmplCABIHeader — shared C ABI type definitions for both core and plugin
|
||||||
|
const tmplCABIHeader = `
|
||||||
|
#ifndef HOMEAGENT_CABI_H
|
||||||
|
#define HOMEAGENT_CABI_H
|
||||||
|
#define HOMEAGENT_ABI_VERSION 1
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// PluginAPI — implemented by the plugin, called by the core
|
||||||
|
typedef struct {
|
||||||
|
int version; int version_min;
|
||||||
|
int (*init_plugin)(char*, char*, char**);
|
||||||
|
int (*start_plugin)(void*, int, char**);
|
||||||
|
int (*stop_plugin)(char**);
|
||||||
|
int (*invoke_tool)(char*, char*, char**, char**);
|
||||||
|
int (*invoke_stage)(char*, char*, char**);
|
||||||
|
int (*invoke_output)(char*, char*, char*, char**);
|
||||||
|
void (*free_string)(char*);
|
||||||
|
} PluginAPI;
|
||||||
|
|
||||||
|
// CoreAPI — implemented by the core, passed to plugin via start_plugin
|
||||||
|
// Uses single dispatch function to avoid function pointer ABI issues
|
||||||
|
typedef struct {
|
||||||
|
int version; int version_min;
|
||||||
|
int (*dispatch)(int method_id, void* ctx, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||||
|
void* ctx;
|
||||||
|
} CoreAPI;
|
||||||
|
|
||||||
|
// Dispatch method IDs (plugin→core SDK calls)
|
||||||
|
enum {
|
||||||
|
CORE_REGISTER_TOOL = 1,
|
||||||
|
CORE_REGISTER_STAGE = 2,
|
||||||
|
CORE_REGISTER_OUTPUT_CH = 3,
|
||||||
|
CORE_REGISTER_PLUGIN_API = 4,
|
||||||
|
CORE_INJECT_TEXT = 5,
|
||||||
|
CORE_INJECT_INTERRUPT_TEXT = 6,
|
||||||
|
CORE_INJECT_TEXT_NO_MEMORY = 7,
|
||||||
|
CORE_SET_AUTO_RESTART = 8,
|
||||||
|
CORE_MEMORY_RECALL = 9,
|
||||||
|
CORE_MEMORY_COMMIT = 10,
|
||||||
|
CORE_MEMORY_INTROSPECT = 11,
|
||||||
|
CORE_MEMORY_MERGE = 12,
|
||||||
|
CORE_MEMORY_PURGE = 13,
|
||||||
|
CORE_DOC_QUERY = 14,
|
||||||
|
CORE_KNOWLEDGE_SEARCH = 15,
|
||||||
|
CORE_SETTINGS_GET = 16,
|
||||||
|
CORE_SETTINGS_SET = 17,
|
||||||
|
CORE_SETTINGS_REGISTER_DEF = 18,
|
||||||
|
CORE_LLM_LIST_SOURCES = 19,
|
||||||
|
CORE_LLM_SET_SOURCE = 20,
|
||||||
|
CORE_SOCIAL_GET_PERSON = 21,
|
||||||
|
CORE_SOCIAL_GET_NETWORK = 22,
|
||||||
|
CORE_SUBSCRIBE = 23,
|
||||||
|
CORE_UNSUBSCRIBE = 24,
|
||||||
|
CORE_FREE_STRING = 25,
|
||||||
|
CORE_SETTINGS_GET_CORE = 26,
|
||||||
|
CORE_SETTINGS_SET_CORE = 27,
|
||||||
|
CORE_SETTINGS_LIST_CORE = 28,
|
||||||
|
CORE_SETTINGS_GET_PLUGIN = 29,
|
||||||
|
CORE_SETTINGS_SET_PLUGIN = 30,
|
||||||
|
CORE_SETTINGS_LIST_PLUGIN = 31,
|
||||||
|
CORE_DOC_INSERT = 32,
|
||||||
|
CORE_DOC_REMOVE = 33,
|
||||||
|
CORE_DOC_STATS = 34,
|
||||||
|
CORE_KNOWLEDGE_ADD = 35,
|
||||||
|
CORE_KNOWLEDGE_LIST = 36,
|
||||||
|
CORE_LLM_CURRENT_SOURCE = 37,
|
||||||
|
CORE_SOCIAL_GET_TRAIT = 38,
|
||||||
|
CORE_SOCIAL_GET_RELATIONS = 39,
|
||||||
|
CORE_SOCIAL_LIST_PERSONS = 40,
|
||||||
|
CORE_TEXT_MEMORY_APPEND = 41,
|
||||||
|
CORE_SETTINGS_LIST = 42,
|
||||||
|
CORE_SETTINGS_DEFS = 43,
|
||||||
|
CORE_SETTINGS_DUMP = 44,
|
||||||
|
CORE_SETTINGS_PLUGINS = 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
|
||||||
|
// tmplLinuxBridge — auto-generated Go bridge for Linux c-shared builds.
|
||||||
|
// Called by plugin's Start() with a PluginSDK that wraps CoreAPI dispatch.
|
||||||
|
// PluginSDK calls go through C ABI → CoreAPI dispatch → core's Go PluginSDK.
|
||||||
|
const tmplLinuxBridge = `package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdlib.h>
|
||||||
|
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- global state ----
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
currentPlg sdk.Plugin
|
||||||
|
coreAPI unsafe.Pointer
|
||||||
|
|
||||||
|
handlerMu sync.RWMutex
|
||||||
|
toolHandlers = map[string]sdk.ToolHandler{}
|
||||||
|
stageHandlers = map[string]sdk.StageHandler{}
|
||||||
|
outputHandlers = map[string]sdk.ToolHandler{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- CoreAPI dispatch helpers ----
|
||||||
|
|
||||||
|
func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error {
|
||||||
|
var c1, c2, c3 *C.char
|
||||||
|
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
|
||||||
|
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
|
||||||
|
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
|
||||||
|
var cErr *C.char
|
||||||
|
if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil {
|
||||||
|
return fmt.Errorf("%s", C.GoString(cErr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) {
|
||||||
|
var c1, c2, c3 *C.char
|
||||||
|
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
|
||||||
|
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
|
||||||
|
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
|
||||||
|
var strResult, cErr *C.char
|
||||||
|
if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil {
|
||||||
|
return "", fmt.Errorf("%s", C.GoString(cErr))
|
||||||
|
}
|
||||||
|
if strResult != nil {
|
||||||
|
result := C.GoString(strResult)
|
||||||
|
C.ha_dispatch(C.int(25), coreAPI, strResult, nil, nil, 0, 0, nil, nil)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- buildPluginSDK: PluginSDK backed by CoreAPI dispatch ----
|
||||||
|
// - ALL SDK methods route through C ABI → CoreAPI → core's PluginSDK
|
||||||
|
// - Handlers for tools/stages/output are stored locally AND registered via dispatch
|
||||||
|
|
||||||
|
func buildPluginSDK(name string) *sdk.PluginSDK {
|
||||||
|
sett := &dispatchSettings{}
|
||||||
|
base := sdk.New(name, sett,
|
||||||
|
func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
handlerMu.Lock()
|
||||||
|
toolHandlers[toolName] = handler
|
||||||
|
handlerMu.Unlock()
|
||||||
|
b, _ := json.Marshal(def)
|
||||||
|
return callVoid(1, toolName, string(b), "", 0, 0)
|
||||||
|
},
|
||||||
|
func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
|
handlerMu.Lock()
|
||||||
|
stageHandlers[string(stage)] = handler
|
||||||
|
handlerMu.Unlock()
|
||||||
|
callVoid(2, string(stage), "", "", 0, 0)
|
||||||
|
},
|
||||||
|
func(name string) error { return callVoid(4, name, "", "", 0, 0) },
|
||||||
|
func(name string, caps int, desc string, handler sdk.ToolHandler) error {
|
||||||
|
handlerMu.Lock()
|
||||||
|
outputHandlers[name] = handler
|
||||||
|
handlerMu.Unlock()
|
||||||
|
return callVoid(3, name, desc, "", caps, 0)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base.SetIOInjector(dispatchIO{})
|
||||||
|
base.SetMemoryAPI(dispatchMemory{})
|
||||||
|
base.SetDocMemoryAPI(dispatchDocMemory{})
|
||||||
|
base.SetKnowledgeAPI(dispatchKnowledge{})
|
||||||
|
base.SetLLMAPI(dispatchLLM{})
|
||||||
|
base.SetSocialAPI(dispatchSocial{})
|
||||||
|
base.SetTextMemoryAPI(dispatchTextMemory{})
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dispatch IO (inline definitions) ----
|
||||||
|
|
||||||
|
type dispatchIO struct{}
|
||||||
|
func (dispatchIO) InjectInterruptText(s, c, t string) { callVoid(6, s, c, t, 0, 0) }
|
||||||
|
func (dispatchIO) InjectText(s, c, t string) { callVoid(5, s, c, t, 0, 0) }
|
||||||
|
func (dispatchIO) InjectTextNoMemory(s, c, t string) { callVoid(7, s, c, t, 0, 0) }
|
||||||
|
|
||||||
|
type dispatchMemory struct{}
|
||||||
|
func (dispatchMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) {
|
||||||
|
b, _ := json.Marshal(q); r, e := callString(9, string(b), "", "", d, 0)
|
||||||
|
if e != nil || r == "" { return nil, nil, e }
|
||||||
|
var v struct{ Entities []sdk.Entity; Relations []sdk.Relation }
|
||||||
|
if e = json.Unmarshal([]byte(r), &v); e != nil { return nil, nil, e }
|
||||||
|
if v.Entities == nil { v.Entities = []sdk.Entity{} }
|
||||||
|
if v.Relations == nil { v.Relations = []sdk.Relation{} }
|
||||||
|
return v.Entities, v.Relations, nil
|
||||||
|
}
|
||||||
|
func (dispatchMemory) Commit(t []sdk.Triple) error { b, _ := json.Marshal(t); return callVoid(10, string(b), "", "", 0, 0) }
|
||||||
|
func (dispatchMemory) Introspect() (map[string]interface{}, error) { r, e := callString(11, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var m map[string]interface{}; return m, json.Unmarshal([]byte(r), &m) }
|
||||||
|
func (dispatchMemory) MergeEntities(s, t string) (int, error) { return 1, callVoid(12, s, t, "", 0, 0) }
|
||||||
|
func (dispatchMemory) Purge(c map[string]string, m string) (int, error) { b, _ := json.Marshal(c); i := 0; if m == "hard" { i = 1 }; return 1, callVoid(13, string(b), "", "", i, 0) }
|
||||||
|
|
||||||
|
type dispatchDocMemory struct{}
|
||||||
|
func (dispatchDocMemory) Query(t string, k int) []*sdk.Doc { r, e := callString(14, t, "", "", k, 0); if e != nil || r == "" { return nil }; var d []*sdk.Doc; json.Unmarshal([]byte(r), &d); return d }
|
||||||
|
func (dispatchDocMemory) Insert(doc *sdk.Doc) error { b, _ := json.Marshal(doc); return callVoid(32, string(b), "", "", 0, 0) }
|
||||||
|
func (dispatchDocMemory) Remove(id string) { callVoid(33, id, "", "", 0, 0) }
|
||||||
|
func (dispatchDocMemory) Stats() map[string]interface{} { r, e := callString(34, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m }
|
||||||
|
|
||||||
|
type dispatchKnowledge struct{}
|
||||||
|
func (dispatchKnowledge) Search(q string, k int) ([]*sdk.Knowledge, error) { r, e := callString(15, q, "", "", k, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.Knowledge; return v, json.Unmarshal([]byte(r), &v) }
|
||||||
|
func (dispatchKnowledge) Add(n, c string) error { return callVoid(35, n, c, "", 0, 0) }
|
||||||
|
func (dispatchKnowledge) List() ([]string, error) { r, e := callString(36, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) }
|
||||||
|
|
||||||
|
type dispatchLLM struct{}
|
||||||
|
func (dispatchLLM) ListSources() []string { r, e := callString(19, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v }
|
||||||
|
func (dispatchLLM) SetSource(n string) error { return callVoid(20, n, "", "", 0, 0) }
|
||||||
|
func (dispatchLLM) CurrentSource() string { r, e := callString(37, "", "", "", 0, 0); if e != nil || r == "" { return "" }; return r }
|
||||||
|
|
||||||
|
type dispatchSocial struct{}
|
||||||
|
func (dispatchSocial) GetPerson(n string) (*sdk.PersonProfile, error) { r, e := callString(21, n, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v sdk.PersonProfile; return &v, json.Unmarshal([]byte(r), &v) }
|
||||||
|
func (dispatchSocial) GetTrait(n, t string) (string, bool) { r, e := callString(38, n, t, "", 0, 0); if e != nil || r == "" { return "", false }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); v, _ := m["value"].(string); ok, _ := m["found"].(bool); return v, ok }
|
||||||
|
func (dispatchSocial) GetRelations(name string) ([]sdk.SocialRelation, error) { r, e := callString(39, name, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []sdk.SocialRelation; return v, json.Unmarshal([]byte(r), &v) }
|
||||||
|
func (dispatchSocial) GetNetwork(n string, d int) ([]*sdk.PersonProfile, error) { r, e := callString(22, n, "", "", d, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.PersonProfile; return v, json.Unmarshal([]byte(r), &v) }
|
||||||
|
func (dispatchSocial) ListPersons() ([]string, error) { r, e := callString(40, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) }
|
||||||
|
|
||||||
|
type dispatchTextMemory struct{}
|
||||||
|
func (dispatchTextMemory) Append(evt sdk.TextEvent) error { b, _ := json.Marshal(evt); return callVoid(41, string(b), "", "", 0, 0) }
|
||||||
|
|
||||||
|
// ---- dispatchSettings (inline) ----
|
||||||
|
|
||||||
|
type dispatchSettings struct{}
|
||||||
|
func (d *dispatchSettings) Get(key string) (interface{}, error) {
|
||||||
|
r, e := callString(16, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) Set(key string, value interface{}) error {
|
||||||
|
b, _ := json.Marshal(value); return callVoid(17, key, string(b), "", 0, 0)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) RegisterDef(def sdk.ConfigDef) { b, _ := json.Marshal(def); callVoid(18, string(b), "", "", 0, 0) }
|
||||||
|
func (d *dispatchSettings) List(prefix string) ([]string, error) {
|
||||||
|
r, e := callString(42, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) GetCore(key string) (interface{}, error) {
|
||||||
|
r, e := callString(26, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) SetCore(key string, value interface{}) error {
|
||||||
|
b, _ := json.Marshal(value); return callVoid(27, key, string(b), "", 0, 0)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) ListCore(prefix string) ([]string, error) {
|
||||||
|
r, e := callString(28, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) GetPlugin(plugin, key string) (interface{}, error) {
|
||||||
|
r, e := callString(29, plugin, key, "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) SetPlugin(plugin, key string, value interface{}) error {
|
||||||
|
b, _ := json.Marshal(value); return callVoid(30, plugin, key, string(b), 0, 0)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) ListPlugin(plugin, prefix string) ([]string, error) {
|
||||||
|
r, e := callString(31, plugin, prefix, "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) Defs(prefix string) []*sdk.ConfigDef {
|
||||||
|
r, e := callString(43, prefix, "", "", 0, 0); if e != nil || r == "" { return nil }; var v []*sdk.ConfigDef; json.Unmarshal([]byte(r), &v); return v
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) Dump() map[string]interface{} {
|
||||||
|
r, e := callString(44, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m
|
||||||
|
}
|
||||||
|
func (d *dispatchSettings) Plugins() []string {
|
||||||
|
r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Go callbacks (called from z_entry.c via C) ----
|
||||||
|
|
||||||
|
//export go_init_plugin
|
||||||
|
func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int {
|
||||||
|
plg, err := NewPlugin(C.GoString(name), nil)
|
||||||
|
if err != nil || plg == nil {
|
||||||
|
if err != nil { *errorOut = C.CString(err.Error()) } else { *errorOut = C.CString("NewPlugin returned nil") }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mu.Lock(); currentPlg = plg; mu.Unlock()
|
||||||
|
_ = configJSON
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export go_start_plugin
|
||||||
|
func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int {
|
||||||
|
mu.Lock()
|
||||||
|
plg := currentPlg
|
||||||
|
coreAPI = coreAPIptr
|
||||||
|
mu.Unlock()
|
||||||
|
_ = coreVersion
|
||||||
|
if plg == nil { *errorOut = C.CString("not initialized"); return 1 }
|
||||||
|
sdk := buildPluginSDK(plg.Name())
|
||||||
|
if err := plg.Start(sdk); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export go_stop_plugin
|
||||||
|
func go_stop_plugin(errorOut **C.char) C.int {
|
||||||
|
mu.Lock()
|
||||||
|
plg := currentPlg
|
||||||
|
currentPlg = nil
|
||||||
|
coreAPI = nil
|
||||||
|
mu.Unlock()
|
||||||
|
if plg != nil {
|
||||||
|
if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export go_invoke_tool
|
||||||
|
func go_invoke_tool(name *C.char, argsJSON *C.char, resultOut **C.char, errorOut **C.char) C.int {
|
||||||
|
goName := C.GoString(name)
|
||||||
|
handlerMu.RLock()
|
||||||
|
h, ok := toolHandlers[goName]
|
||||||
|
handlerMu.RUnlock()
|
||||||
|
if !ok { *errorOut = C.CString("tool not found"); return 1 }
|
||||||
|
var args map[string]interface{}
|
||||||
|
if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) }
|
||||||
|
r, err := h(args)
|
||||||
|
if err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||||
|
b, _ := json.Marshal(r)
|
||||||
|
*resultOut = C.CString(string(b))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export go_invoke_stage
|
||||||
|
func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int {
|
||||||
|
goStage := C.GoString(stage)
|
||||||
|
handlerMu.RLock()
|
||||||
|
h, ok := stageHandlers[goStage]
|
||||||
|
handlerMu.RUnlock()
|
||||||
|
if !ok { return 0 }
|
||||||
|
sc := &sdk.StageContext{}
|
||||||
|
if ctxJSON != nil {
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(C.GoString(ctxJSON)), &m); err == nil {
|
||||||
|
if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
|
||||||
|
if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
|
||||||
|
if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
|
||||||
|
if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
|
||||||
|
if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
|
||||||
|
if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
|
||||||
|
if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
|
||||||
|
if v, _ := m["response"].(string); v != "" { sc.Response = &v }
|
||||||
|
if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
|
||||||
|
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
|
||||||
|
}
|
||||||
|
if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
|
||||||
|
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export go_invoke_output
|
||||||
|
func go_invoke_output(channel *C.char, msgType *C.char, payloadJSON *C.char, errorOut **C.char) C.int {
|
||||||
|
goChan := C.GoString(channel)
|
||||||
|
handlerMu.RLock()
|
||||||
|
h, ok := outputHandlers[goChan]
|
||||||
|
handlerMu.RUnlock()
|
||||||
|
if !ok { return 0 }
|
||||||
|
// payloadJSON contains the full args JSON from output_send (e.g. {"content":"...","user_id":123})
|
||||||
|
var args map[string]interface{}
|
||||||
|
if payloadJSON != nil {
|
||||||
|
json.Unmarshal([]byte(C.GoString(payloadJSON)), &args)
|
||||||
|
}
|
||||||
|
if _, err := h(args); err != nil { *errorOut = C.CString(err.Error()); return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//export go_free_string
|
||||||
|
func go_free_string(ptr *C.char) { C.free(unsafe.Pointer(ptr)) }
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
`
|
||||||
|
|
||||||
|
// tmplPluginInitC — C entry point for the plugin .so file.
|
||||||
|
// Contains PluginAPI, CoreAPI (single dispatch), and ha_dispatch bridge.
|
||||||
|
const tmplPluginInitC = `#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define HOMEAGENT_ABI_VERSION 1
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int version; int version_min;
|
||||||
|
int (*init_plugin)(char*, char*, char**);
|
||||||
|
int (*start_plugin)(void*, int, char**);
|
||||||
|
int (*stop_plugin)(char**);
|
||||||
|
int (*invoke_tool)(char*, char*, char**, char**);
|
||||||
|
int (*invoke_stage)(char*, char*, char**);
|
||||||
|
int (*invoke_output)(char*, char*, char*, char**);
|
||||||
|
void (*free_string)(char*);
|
||||||
|
} PluginAPI;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int version; int version_min;
|
||||||
|
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
|
||||||
|
void* ctx;
|
||||||
|
} CoreAPI;
|
||||||
|
|
||||||
|
extern int go_init_plugin(char*, char*, char**);
|
||||||
|
extern int go_start_plugin(void*, int, char**);
|
||||||
|
extern int go_stop_plugin(char**);
|
||||||
|
extern int go_invoke_tool(char*, char*, char**, char**);
|
||||||
|
extern int go_invoke_stage(char*, char*, char**);
|
||||||
|
extern int go_invoke_output(char*, char*, char*, char**);
|
||||||
|
extern void go_free_string(char*);
|
||||||
|
|
||||||
|
int c_init_plugin(char* n, char* c, char** e) { return go_init_plugin(n, c, e); }
|
||||||
|
int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); }
|
||||||
|
int c_stop_plugin(char** e) { return go_stop_plugin(e); }
|
||||||
|
int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); }
|
||||||
|
int c_invoke_stage(char* s, char* c, char** e) { return go_invoke_stage(s, c, e); }
|
||||||
|
int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); }
|
||||||
|
void c_free_string(char* p) { go_free_string(p); }
|
||||||
|
|
||||||
|
// ha_dispatch — called by Go bridge, passes through to CoreAPI dispatch
|
||||||
|
int ha_dispatch(int id, void* api, char* s1, char* s2, char* s3, int i1, int i2, char** r, char** e) {
|
||||||
|
CoreAPI* a = (CoreAPI*)api;
|
||||||
|
if (!a || !a->dispatch) return 1;
|
||||||
|
return a->dispatch(id, a->ctx, s1, s2, s3, i1, i2, r, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginAPI* plugin_init(void) {
|
||||||
|
static PluginAPI api;
|
||||||
|
memset(&api, 0, sizeof(api));
|
||||||
|
api.version = HOMEAGENT_ABI_VERSION; api.version_min = HOMEAGENT_ABI_VERSION;
|
||||||
|
api.init_plugin = c_init_plugin; api.start_plugin = c_start_plugin; api.stop_plugin = c_stop_plugin;
|
||||||
|
api.invoke_tool = c_invoke_tool; api.invoke_stage = c_invoke_stage; api.invoke_output = c_invoke_output;
|
||||||
|
api.free_string = c_free_string;
|
||||||
|
return &api;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplReadme = `# {{.Plg.Name}}
|
||||||
|
|
||||||
|
{{.Plg.Description}}
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
` + "```bash" + `
|
||||||
|
plugindev build
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Upload the .hmap file through the Plugin Manager API.
|
||||||
|
`
|
||||||
1
tools/plugindev/templates.go.new_header
Normal file
1
tools/plugindev/templates.go.new_header
Normal file
@ -0,0 +1 @@
|
|||||||
|
I need to rewrite the preamble section. Let me use a python script to make this change.
|
||||||
19
tools/plugindev/templates/README.md.tmpl
Normal file
19
tools/plugindev/templates/README.md.tmpl
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# {{.Plg.Name}}
|
||||||
|
|
||||||
|
{{.Plg.Description}}
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plugindev build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Upload the `.hmap` file through the Plugin Manager API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/plugins \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @dist/<name_en_snake>_linux_amd64.hmap
|
||||||
|
```
|
||||||
55
tools/plugindev/templates/main.go.tmpl
Normal file
55
tools/plugindev/templates/main.go.tmpl
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(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.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.{{.Plg.Name}}.example",
|
||||||
|
Default: "hello",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "示例配置",
|
||||||
|
Description: "An example configuration key",
|
||||||
|
Category: "{{.Plg.Name}}",
|
||||||
|
})
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello",
|
||||||
|
Description: "A hello world tool",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleHello)
|
||||||
|
|
||||||
|
fmt.Printf("[%s] started\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
fmt.Printf("[%s] stopped\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "Hello from {{.Plg.Name}} plugin!",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
45
tools/plugindev/templates/main.lua.tmpl
Normal file
45
tools/plugindev/templates/main.lua.tmpl
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
local plugin = {
|
||||||
|
name = "{{.Plg.Name}}",
|
||||||
|
tools = {},
|
||||||
|
stages = {},
|
||||||
|
settings = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin:start(sdk)
|
||||||
|
sdk.log("{{.Plg.Name}} plugin starting...")
|
||||||
|
|
||||||
|
-- Register a configuration setting
|
||||||
|
-- sdk.settings.register({
|
||||||
|
-- key = "{{.Plg.Name}}.example",
|
||||||
|
-- default = "hello",
|
||||||
|
-- type = "string",
|
||||||
|
-- display_name = "Example Config",
|
||||||
|
-- description = "An example configuration key"
|
||||||
|
-- })
|
||||||
|
|
||||||
|
-- Register a tool
|
||||||
|
local ok = sdk:register_tool("{{.Plg.Name}}_hello", {
|
||||||
|
name = "{{.Plg.Name}}_hello",
|
||||||
|
description = "A hello world tool",
|
||||||
|
parameters = {
|
||||||
|
type = "object",
|
||||||
|
properties = {}
|
||||||
|
}
|
||||||
|
}, function(args)
|
||||||
|
return { content = "Hello from {{.Plg.Name}} plugin!" }
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
sdk.log("error: failed to register tool")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
sdk.log("{{.Plg.Name}} plugin started")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function plugin:stop()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return plugin
|
||||||
11
tools/plugindev/templates/plg.json.tmpl
Normal file
11
tools/plugindev/templates/plg.json.tmpl
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "{{.Plg.Name}}",
|
||||||
|
"name_zh": "{{.Plg.NameZh}}",
|
||||||
|
"name_en": "{{.Plg.NameEn}}",
|
||||||
|
"version": "{{.Plg.Version}}",
|
||||||
|
"description": "{{.Plg.Description}}",
|
||||||
|
"author": "{{.Plg.Author}}",
|
||||||
|
"entry": "{{.Plg.Entry}}",
|
||||||
|
"tags": {{.Plg.Tags}},
|
||||||
|
"targets": "{{.Plg.Targets}}"
|
||||||
|
}
|
||||||
13
tools/plugindev/testplugin/README.md
Normal file
13
tools/plugindev/testplugin/README.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# testplugin
|
||||||
|
|
||||||
|
testplugin plugin
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plugindev build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Upload the .hmap file through the Plugin Manager API.
|
||||||
7
tools/plugindev/testplugin/go.mod
Normal file
7
tools/plugindev/testplugin/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module testplugin
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk
|
||||||
11
tools/plugindev/testplugin/main.go
Normal file
11
tools/plugindev/testplugin/main.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows || !cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return NewPluginFactory(name, config)
|
||||||
|
}
|
||||||
11
tools/plugindev/testplugin/plg.json
Normal file
11
tools/plugindev/testplugin/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "testplugin",
|
||||||
|
"name_zh": "中文名",
|
||||||
|
"name_en": "Testplugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "testplugin plugin",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["testplugin"],
|
||||||
|
"targets": "linux/amd64,windows/amd64"
|
||||||
|
}
|
||||||
57
tools/plugindev/testplugin/plugin.go
Normal file
57
tools/plugindev/testplugin/plugin.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.testplugin.example",
|
||||||
|
Default: "hello",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "示例配置",
|
||||||
|
Description: "An example configuration key",
|
||||||
|
Category: "testplugin",
|
||||||
|
})
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello",
|
||||||
|
Description: "A hello world tool",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleHello)
|
||||||
|
|
||||||
|
fmt.Printf("[%s] started\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
fmt.Printf("[%s] stopped\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "Hello from testplugin plugin!",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPluginFactory creates a Plugin instance. Called by both Linux entry (main.go)
|
||||||
|
// and Windows bridge (z_bridge_gen.go) to avoid naming conflict with C export.
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user