mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-22 01:48:03 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c91739d670 | |||
| 6527a40539 | |||
| 392f391f68 | |||
| cca9fdce9c | |||
| b6e30f9279 | |||
| 8e5610c494 | |||
| 1796395668 | |||
| f3d87ec35f | |||
| aee63a4f98 | |||
| fb07081929 | |||
| db5d3133ea | |||
| 12a8e99892 | |||
| 62447e3952 | |||
| bc1a005885 | |||
| 2b54814037 | |||
| 429fe9e1b9 | |||
| 87136057b1 | |||
| 84bf100a12 | |||
| 78ef7998c2 | |||
| b166697dd7 | |||
| 4d01e75282 | |||
| c5bcae9404 | |||
| c7c66b8d39 | |||
| 04837f237d | |||
| 1fa3c843ab | |||
| cb7999ca71 | |||
| 34f91ebd1a | |||
| 5e0c8d5a40 | |||
| e182b89983 | |||
| e030a9b9f9 | |||
| 7705e4eb3b | |||
| ab3c0bfd2d | |||
| 1a3e72e899 | |||
| 52dc22f86f | |||
| 75ae2b4692 | |||
| 95dad649a8 | |||
| 1bdec9507d | |||
| 1834a66bff | |||
| 348a1447c8 | |||
| 4f9e064721 |
20
.gitignore
vendored
20
.gitignore
vendored
@ -8,15 +8,23 @@ plugin.json
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Binaries
|
||||
*.exe
|
||||
|
||||
# Test artifacts
|
||||
testdist/
|
||||
|
||||
# Logs
|
||||
*.logz_bridge_gen.go\nz_entry.c\nbuild/\ndist/
|
||||
*.log
|
||||
|
||||
# Generated bridge files
|
||||
z_bridge_gen.go
|
||||
z_entry.c
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Binaries (except pre-built distributions in bin/)
|
||||
/plugindev
|
||||
*_debug*
|
||||
|
||||
# Pre-built plugindev binaries in bin/ should be tracked
|
||||
!bin/plugindev*
|
||||
!bin/*.exe
|
||||
|
||||
# plugindev binary in tools/
|
||||
tools/plugindev/plugindev
|
||||
|
||||
191
README.md
191
README.md
@ -23,7 +23,8 @@ type Plugin interface {
|
||||
| 分类 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输出通道,caps 为能力位掩码 |
|
||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,caps 为能力位掩码 |
|
||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||
@ -47,10 +48,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||
```
|
||||
|
||||
### ChannelDef
|
||||
|
||||
```go
|
||||
type ChannelDef struct {
|
||||
NoMemory bool // 通道输入/输出不参与记忆计算(向量/关键词/蒸馏),原文保留
|
||||
Cleaner func(string) string // 可选:计算层过滤函数(不改原文)
|
||||
}
|
||||
```
|
||||
|
||||
`ChannelDef` 控制通道在记忆计算层的行为,与 `ToolDef` 的 `NoMemory`/`Cleaner` 语义一致。
|
||||
|
||||
### 输入通道
|
||||
|
||||
```go
|
||||
sdk.RegisterInputChannel("qq", ChannelDef{
|
||||
NoMemory: true,
|
||||
Cleaner: func(text string) string { return strings.TrimSpace(text) },
|
||||
})
|
||||
```
|
||||
|
||||
### 输出通道
|
||||
|
||||
```go
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler)
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
||||
```
|
||||
|
||||
handler 接收三个参数:
|
||||
@ -95,6 +116,20 @@ Triple 数据结构新增字段:
|
||||
- `SubjectType` — 主体类型
|
||||
- `ObjectType` — 客体类型
|
||||
|
||||
### ToolDef 字段说明
|
||||
|
||||
`RegisterTool` 的 `def` 参数类型为 `sdk.ToolDef`,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `Name` | `string` | 工具名,建议插件名前缀避免冲突 |
|
||||
| `Description` | `string` | 工具描述,LLM 据此选择调用 |
|
||||
| `Parameters` | `map[string]interface{}` | JSON Schema 格式参数定义 |
|
||||
| `NoMemory` | `bool` | 默认为 `false`;设为 `true` 时输出不参与向量/jieba/蒸馏计算(原文保留) |
|
||||
| `Cleaner` | `func(string) string` | 可选,输出进入计算层前的清洗函数(如 JSON 提取 `.content`) |
|
||||
|
||||
`NoMemory` 和 `Cleaner` 的详细设计意图参见核心仓 `docs/zh/PLUGIN_DEV.md`。
|
||||
|
||||
### New 构造函数
|
||||
|
||||
`New()` 由内核在加载插件时调用,插件开发者无需手动构造 PluginSDK:
|
||||
@ -103,35 +138,80 @@ Triple 数据结构新增字段:
|
||||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||||
```
|
||||
|
||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。
|
||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
||||
|
||||
## plugindev 工具链
|
||||
|
||||
`plugindev` 提供插件开发全流程支持:
|
||||
`plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制(linux/darwin/windows × amd64/arm64),下载后直接加入 PATH 即可:
|
||||
|
||||
```bash
|
||||
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64
|
||||
chmod +x plugindev
|
||||
```
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) |
|
||||
| `plugindev build` | 构建插件,输出 .hmap 包 |
|
||||
| `plugindev clean` | 清理构建产物 |
|
||||
| `plugindev debug` | 本地调试模式运行插件 |
|
||||
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
||||
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
||||
| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
||||
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
||||
| `plugindev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
||||
|
||||
支持 **Go** 和 **Lua** 两种插件语言。
|
||||
|
||||
### build 命令 flags
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--outdir <dir>` | 输出目录(默认 `dist`,可覆盖 plg.json 中的 `outdir`) |
|
||||
| `--target <os/arch>` | 构建目标(如 `linux/amd64`),可重复指定(追加到 plg.json 中的 targets) |
|
||||
| `--bundle` | 强制 bundle 模式(同时编译 linux/amd64, darwin/amd64, windows/amd64) |
|
||||
| `--no-bundle` | 关闭 bundle 模式,仅按 targets 逐个编译 |
|
||||
| `--sdk-path <path>` | 指定 SDK 源码路径(覆盖 plg.json 中的 `sdk_path`) |
|
||||
| `--replace <from=to>` / `-R` | Go 模块替换(追加到 plg.json 中的 replaces),`from` 为模块路径,`to` 为本地路径 |
|
||||
|
||||
### plg.json 清单格式
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"name": "weather",
|
||||
"name_zh": "天气查询",
|
||||
"name_en": "Weather",
|
||||
"version": "1.0.0",
|
||||
"lang": "go",
|
||||
"entry": "main.go",
|
||||
"description": "插件描述",
|
||||
"channels": ["my-channel"],
|
||||
"dependencies": {}
|
||||
"description": "天气查询插件",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["weather", "forecast"],
|
||||
"targets": "linux/amd64,windows/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {
|
||||
"github.com/example/pkg": "../local/pkg"
|
||||
},
|
||||
"source_dirs": [
|
||||
"../shared-lib"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | string | 插件标识名 |
|
||||
| `name_zh` | string | 中文名 |
|
||||
| `name_en` | string | 英文名 |
|
||||
| `version` | string | 版本号 |
|
||||
| `description` | string | 插件描述 |
|
||||
| `author` | string | 作者 |
|
||||
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua`) |
|
||||
| `tags` | string[] | 标签 |
|
||||
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) |
|
||||
| `outdir` | string | 输出目录(默认 `dist`) |
|
||||
| `bundle` | bool | 是否 bundle 模式(同时编译多平台,默认 `true`) |
|
||||
| `sdk_path` | string | SDK 源码路径(覆盖自动检测的 SDK 路径) |
|
||||
| `go_version` | string | Go 版本(如 `1.21`,默认从 SDK 的 go.mod 读取) |
|
||||
| `replaces` | object | Go 模块替换,key=模块路径,value=本地路径 |
|
||||
| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入,用于引入 `thirdpart/` 外部的共享代码) |
|
||||
|
||||
### .hmap 包格式
|
||||
|
||||
`.hmap` 为 ZIP 归档,包含:
|
||||
@ -139,14 +219,53 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
||||
- `plugin.json` — 插件元数据
|
||||
- `plugin.so` — Go 编译产物(Linux)
|
||||
- `plugin.dll` — Go 编译产物(Windows)
|
||||
- `plugin.dylib` — Go 编译产物(macOS,bundle 模式)
|
||||
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||
|
||||
## 插件生命周期
|
||||
|
||||
### 入口函数
|
||||
|
||||
插件必须导出 `NewPluginFactory` 入口函数(Go)或 `start()` 函数(Lua):
|
||||
|
||||
**Go 插件** — 实现 `Plugin` 接口并导出工厂函数:
|
||||
|
||||
```go
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
```
|
||||
|
||||
该函数由内核在加载插件时调用,`name` 为插件名,`config` 为 `skill.json` 中的配置(如有)。
|
||||
|
||||
**Lua 插件** — 返回包含 `start(sdk)` 和 `stop()` 方法的 table:
|
||||
|
||||
```lua
|
||||
local plugin = { name = "my-plugin" }
|
||||
function plugin.start(sdk) -- 注册工具等 end
|
||||
function plugin.stop() end
|
||||
return plugin
|
||||
```
|
||||
|
||||
### 启动与停止
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
||||
- `Stop() error` — 插件停止,释放资源
|
||||
- `sdk.RegisterStopHandler(fn func())` — 注册停止清理回调。内核(内置插件)或 z_bridge(外部插件)会在调用插件 `Stop()` **之前**统一执行已注册的 handler(后注册先执行,执行后清空、幂等)。适合做持久化落盘、取消后台任务等清理:此时插件内存状态仍然新鲜,避免在 `Stop()` 阶段以陈旧状态写回导致数据复活。
|
||||
|
||||
### 删除清理(onRemove)
|
||||
|
||||
`Stop`/`RegisterStopHandler` 在插件**停止**(含重载、禁用)时执行;`RegisterOnRemoveHandler` 仅在插件被**卸载(删除)**时执行一次,重载/禁用不触发:
|
||||
|
||||
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
||||
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
||||
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
|
||||
|
||||
```go
|
||||
sdk.RegisterOnRemoveHandler(func() {
|
||||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||
})
|
||||
```
|
||||
|
||||
### 自动重启
|
||||
|
||||
@ -171,17 +290,23 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
## 示例插件
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| a2a | Agent-to-Agent 协议通信 |
|
||||
| bili | Bilibili 视频下载 |
|
||||
| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch) |
|
||||
| editdoc | 文档编辑 |
|
||||
| files | 文件管理 |
|
||||
| memo | 备忘录/记忆 |
|
||||
| ocr | 光学字符识别 |
|
||||
| qq | QQ 消息集成 |
|
||||
| sanitizer | 内容清洗/安全过滤 |
|
||||
| 插件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| [weather](example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||
| [luademo](example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||
| [qq](example/qq) | Go | QQ 消息集成(NapCat),17 个工具,输入/输出通道完整对接 |
|
||||
| [a2a](example/a2a) | Go | Agent-to-Agent 协议通信 |
|
||||
| [ai_image](example/ai_image) | Go | AI 图片生成 |
|
||||
| [bili](example/bili) | Go | Bilibili 视频下载 |
|
||||
| [browser](example/browser) | Go | 网络搜索、网页抓取、浏览器渲染 |
|
||||
| [calendar](example/calendar) | Go | 日历管理 |
|
||||
| [editdoc](example/editdoc) | Go | 文档编辑 |
|
||||
| [files](example/files) | Go | 文件管理 |
|
||||
| [memo](example/memo) | Go | 备忘录(PreAction 注入 + 定时提醒) |
|
||||
| [music](example/music) | Go | 音乐播放 |
|
||||
| [ocr](example/ocr) | Go | 光学字符识别 |
|
||||
| [rss](example/rss) | Go | RSS 订阅 |
|
||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||
|
||||
## 构建与安装
|
||||
|
||||
@ -191,15 +316,21 @@ enabled := sdk.AutoRestart()
|
||||
plugindev build
|
||||
```
|
||||
|
||||
输出 `.hmap` 包到项目目录。
|
||||
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`)。
|
||||
|
||||
### 安装
|
||||
|
||||
通过 pluginmgr HTTP API 安装:
|
||||
通过 pluginmgr HTTP API 安装(端口默认 9876,仅监听 127.0.0.1,无鉴权):
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
# 本地路径
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||||
|
||||
# 直接上传二进制
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
--data-binary @dist/my-plugin.hmap
|
||||
```
|
||||
|
||||
或手动将 `.hmap` 放入插件目录后重启平台。
|
||||
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
||||
|
||||
137
README_EN.md
137
README_EN.md
@ -23,7 +23,8 @@ 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 |
|
||||
| Input Channel | `RegisterInputChannel(name, def)` | Register input channel with `ChannelDef` (NoMemory/Cleaner) |
|
||||
| Output Channel | `RegisterOutputChannel(name, caps, desc, def, handler)` | Register output channel with `ChannelDef` and capability bitmask |
|
||||
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
|
||||
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
|
||||
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
|
||||
@ -47,10 +48,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||
```
|
||||
|
||||
### ChannelDef
|
||||
|
||||
```go
|
||||
type ChannelDef struct {
|
||||
NoMemory bool // Channel input/output skips memory computation (vector/keyword/distill), original text preserved
|
||||
Cleaner func(string) string // Optional: computation layer filter (does not modify original text)
|
||||
}
|
||||
```
|
||||
|
||||
`ChannelDef` controls channel behavior in the memory computation layer, with the same semantics as `ToolDef.NoMemory`/`Cleaner`.
|
||||
|
||||
### Input Channels
|
||||
|
||||
```go
|
||||
sdk.RegisterInputChannel("qq", ChannelDef{
|
||||
NoMemory: true,
|
||||
Cleaner: func(text string) string { return strings.TrimSpace(text) },
|
||||
})
|
||||
```
|
||||
|
||||
### Output Channels
|
||||
|
||||
```go
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler)
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", ChannelDef{}, handler)
|
||||
```
|
||||
|
||||
The handler receives three arguments:
|
||||
@ -95,6 +116,20 @@ The Triple data structure includes additional fields:
|
||||
- `SubjectType` — subject type
|
||||
- `ObjectType` — object type
|
||||
|
||||
### ToolDef Field Reference
|
||||
|
||||
The `def` parameter of `RegisterTool` is of type `sdk.ToolDef`, with the following fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Name` | `string` | Tool name, use plugin name prefix to avoid conflicts |
|
||||
| `Description` | `string` | Tool description, LLM uses this for tool selection |
|
||||
| `Parameters` | `map[string]interface{}` | JSON Schema parameter definition |
|
||||
| `NoMemory` | `bool` | Default `false`; when `true`, output skips vector/jieba/distill computation (original text preserved) |
|
||||
| `Cleaner` | `func(string) string` | Optional, filters output before computation layer (e.g., extract `.content` from JSON) |
|
||||
|
||||
For detailed design rationale of `NoMemory` and `Cleaner`, see `docs/en/PLUGIN_DEV.md` in the core repository.
|
||||
|
||||
### New Constructor
|
||||
|
||||
`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually:
|
||||
@ -122,16 +157,42 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"name": "weather",
|
||||
"name_zh": "天气查询",
|
||||
"name_en": "Weather",
|
||||
"version": "1.0.0",
|
||||
"lang": "go",
|
||||
"entry": "main.go",
|
||||
"description": "Plugin description",
|
||||
"channels": ["my-channel"],
|
||||
"dependencies": {}
|
||||
"description": "Weather plugin",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["weather", "forecast"],
|
||||
"targets": "linux/amd64,windows/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {
|
||||
"github.com/example/pkg": "../local/pkg"
|
||||
},
|
||||
"source_dirs": [
|
||||
"../shared-lib"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | string | Plugin identifier |
|
||||
| `name_zh` | string | Chinese name |
|
||||
| `name_en` | string | English name |
|
||||
| `version` | string | Version |
|
||||
| `description` | string | Plugin description |
|
||||
| `author` | string | Author |
|
||||
| `entry` | string | Entry file (`plugin.so` / `main.lua`) |
|
||||
| `tags` | string[] | Tags |
|
||||
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
||||
| `outdir` | string | Output directory (default `dist`) |
|
||||
| `bundle` | bool | Bundle mode (build all platforms at once) |
|
||||
| `replaces` | object | Go module replacements, key=module path, value=local path |
|
||||
| `source_dirs` | string[] | Additional source search paths (auto-imported at build time) |
|
||||
|
||||
### .hmap Package Format
|
||||
|
||||
`.hmap` is a ZIP archive containing:
|
||||
@ -147,6 +208,21 @@ Supports both **Go** and **Lua** plugin languages.
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||||
- `Stop() error` — Plugin shutdown, release resources
|
||||
- `sdk.RegisterStopHandler(fn func())` — Register a shutdown cleanup callback. The kernel (for built-in plugins) or z_bridge (for external plugins) runs all registered handlers **before** calling the plugin's `Stop()` (LIFO order, cleared after running — idempotent). Use it for persistence and cancelling background work: plugin memory is still fresh at that point, avoiding stale-state write-backs that resurrect deleted data.
|
||||
|
||||
### Remove Cleanup (onRemove)
|
||||
|
||||
`Stop` / `RegisterStopHandler` run whenever the plugin **stops** (including reload and disable); `RegisterOnRemoveHandler` runs **only once when the plugin is uninstalled (removed)** — never on reload or disable:
|
||||
|
||||
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
|
||||
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
|
||||
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
|
||||
|
||||
```go
|
||||
sdk.RegisterOnRemoveHandler(func() {
|
||||
os.Remove(filepath.Join(dataDir, "events.json"))
|
||||
})
|
||||
```
|
||||
|
||||
### Auto-Restart
|
||||
|
||||
@ -171,18 +247,23 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
||||
|
||||
## Example Plugins
|
||||
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| a2a | Agent-to-Agent protocol communication |
|
||||
| bili | Bilibili data fetching |
|
||||
| editdoc | Document editing |
|
||||
| files | File management |
|
||||
| memo | Memo/notes |
|
||||
| ocr | Optical character recognition |
|
||||
| qq | QQ messaging integration |
|
||||
| sanitizer | Content sanitization/safety filtering |
|
||||
| web | Web browsing and interaction |
|
||||
| webfetch | Web content fetching |
|
||||
| Plugin | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||
| [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||
| [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
|
||||
| [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
|
||||
| [ai_image](example/ai_image) | Go | AI image generation |
|
||||
| [bili](example/bili) | Go | Bilibili video downloading |
|
||||
| [browser](example/browser) | Go | Web search, page fetching, browser rendering |
|
||||
| [calendar](example/calendar) | Go | Calendar management |
|
||||
| [editdoc](example/editdoc) | Go | Document editing |
|
||||
| [files](example/files) | Go | File management |
|
||||
| [memo](example/memo) | Go | Memos (PreAction injection + scheduled reminders) |
|
||||
| [music](example/music) | Go | Music playback |
|
||||
| [ocr](example/ocr) | Go | Optical character recognition |
|
||||
| [rss](example/rss) | Go | RSS subscriptions |
|
||||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
||||
|
||||
## Building & Installing
|
||||
|
||||
@ -192,15 +273,21 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
||||
plugindev build
|
||||
```
|
||||
|
||||
Outputs a `.hmap` package to the project directory.
|
||||
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
|
||||
|
||||
### Install
|
||||
|
||||
Via pluginmgr HTTP API:
|
||||
Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
# Local path
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "/path/to/my-plugin.hmap"}'
|
||||
|
||||
# Upload binary directly
|
||||
curl -X POST http://127.0.0.1:9876/plugins \
|
||||
--data-binary @dist/my-plugin.hmap
|
||||
```
|
||||
|
||||
Or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
|
||||
BIN
bin/plugindev_darwin_amd64
Executable file
BIN
bin/plugindev_darwin_amd64
Executable file
Binary file not shown.
BIN
bin/plugindev_darwin_arm64
Executable file
BIN
bin/plugindev_darwin_arm64
Executable file
Binary file not shown.
BIN
bin/plugindev_linux_amd64
Executable file
BIN
bin/plugindev_linux_amd64
Executable file
Binary file not shown.
BIN
bin/plugindev_linux_arm64
Executable file
BIN
bin/plugindev_linux_arm64
Executable file
Binary file not shown.
BIN
bin/plugindev_windows_amd64.exe
Executable file
BIN
bin/plugindev_windows_amd64.exe
Executable file
Binary file not shown.
@ -2,6 +2,6 @@ module a2a
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
2
example/a2a/go.sum
Normal file
2
example/a2a/go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=
|
||||
@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["a2a", "agent", "interop"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -9,15 +9,18 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
server *http.Server
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
srvMu sync.Mutex
|
||||
server *http.Server
|
||||
serverAddr string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -28,7 +31,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
tp := p.name + "_"
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin." + p.name + ".listen", Default: "127.0.0.1:12000",
|
||||
Key: "listen", Default: "127.0.0.1:12000",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
@ -46,6 +49,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
"required": []string{"agent_url", "query"},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Content string }
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
|
||||
return r.Content
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleA2AQuery)
|
||||
|
||||
s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{
|
||||
@ -59,10 +69,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
}, p.handleA2ADiscover)
|
||||
|
||||
// Management tools
|
||||
s.RegisterTool(tp+"a2a_configure", sdk.ToolDef{
|
||||
Name: tp + "a2a_configure", Description: "修改 A2A 插件配置并自动重启服务。支持动态更改监听地址等参数。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12000,设为空字符串禁用 HTTP 服务)"},
|
||||
},
|
||||
},
|
||||
}, p.handleConfigure)
|
||||
|
||||
s.RegisterTool(tp+"a2a_restart", sdk.ToolDef{
|
||||
Name: tp + "a2a_restart", Description: "重启 A2A HTTP 服务端。当连接异常或配置变更后需要重新加载时使用。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleRestart)
|
||||
|
||||
s.RegisterTool(tp+"a2a_status", sdk.ToolDef{
|
||||
Name: tp + "a2a_status", Description: "查看 A2A 插件的运行状态,包括监听地址和当前配置。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleStatus)
|
||||
|
||||
// Inbound HTTP server
|
||||
if addr, _ := s.Settings().Get("plugin." + p.name + ".listen"); addr != nil {
|
||||
if addr, _ := s.Settings().Get("listen"); addr != nil {
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
p.startServer(addrStr)
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
log.Printf("[%s] start A2A server: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,15 +110,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopServer()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) stopServer() {
|
||||
p.srvMu.Lock()
|
||||
defer p.srvMu.Unlock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
p.server = nil
|
||||
p.serverAddr = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) {
|
||||
func (p *Plugin) startServer(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||
@ -87,17 +134,27 @@ func (p *Plugin) startServer(addr string) {
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("[%s] listen %s: %v", p.name, addr, err)
|
||||
return
|
||||
return fmt.Errorf("listen %s: %v", addr, err)
|
||||
}
|
||||
|
||||
p.server = &http.Server{Handler: mux}
|
||||
srv := &http.Server{Handler: mux}
|
||||
addrStr := listener.Addr().String()
|
||||
|
||||
p.srvMu.Lock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
p.server = srv
|
||||
p.serverAddr = addrStr
|
||||
p.srvMu.Unlock()
|
||||
|
||||
go func() {
|
||||
log.Printf("[%s] A2A server on %s", p.name, listener.Addr())
|
||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] A2A server on %s", p.name, addrStr)
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||
@ -365,6 +422,62 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
// ---- Management Handlers ----
|
||||
|
||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||
listen, _ := args["listen"].(string)
|
||||
listen = strings.TrimSpace(listen)
|
||||
|
||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||
}
|
||||
|
||||
if listen == "" || listen == "off" || listen == "disabled" {
|
||||
p.stopServer()
|
||||
return "A2A HTTP 服务已禁用(listen 设为空)", nil
|
||||
}
|
||||
|
||||
if err := p.startServer(listen); err != nil {
|
||||
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
|
||||
p.stopServer()
|
||||
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
|
||||
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
|
||||
}
|
||||
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
|
||||
}
|
||||
|
||||
p.srvMu.Lock()
|
||||
listening := p.serverAddr
|
||||
p.srvMu.Unlock()
|
||||
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
|
||||
p.srvMu.Lock()
|
||||
serverRunning := p.server != nil
|
||||
listening := p.serverAddr
|
||||
p.srvMu.Unlock()
|
||||
if !serverRunning {
|
||||
listening = "未运行"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s",
|
||||
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning]), nil
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
7
example/acp/go.mod
Normal file
7
example/acp/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module acp
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/acp/main.go
Normal file
11
example/acp/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
15
example/acp/plg.json
Normal file
15
example/acp/plg.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "acp",
|
||||
"name_zh": "ACP 代理通信",
|
||||
"name_en": "ACP Agent Client Protocol",
|
||||
"version": "1.0.0",
|
||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["acp", "agent", "interop"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
528
example/acp/plugin.go
Normal file
528
example/acp/plugin.go
Normal file
@ -0,0 +1,528 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
|
||||
// - 服务端:POST /api/session (JSON-RPC:session/new / session/update),
|
||||
// 请求注入本 Agent,另提供 GET /api/session?id=xxx SSE 事件流。
|
||||
// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
srvMu sync.Mutex
|
||||
server *http.Server
|
||||
serverID string
|
||||
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*sessionState
|
||||
}
|
||||
|
||||
type sessionState struct {
|
||||
ID string
|
||||
Replying []map[string]interface{}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.sessions = make(map[string]*sessionState)
|
||||
tp := p.name + "_"
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "listen", Default: "127.0.0.1:12001",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
s.RegisterTool(tp+"acp_query", sdk.ToolDef{
|
||||
Name: tp + "acp_query", Description: "向远程 ACP Agent(如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001)发起一个会话请求并等待回复,返回其最终回答文本,兼容 SSE 型与同步 JSON 型 ACP 服务端",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
||||
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
|
||||
},
|
||||
"required": []string{"server_url", "prompt"},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct {
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" {
|
||||
return r.Reply
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleAcpQuery)
|
||||
|
||||
s.RegisterTool(tp+"acp_configure", sdk.ToolDef{
|
||||
Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001,设为空禁用)"},
|
||||
},
|
||||
},
|
||||
}, p.handleConfigure)
|
||||
|
||||
s.RegisterTool(tp+"acp_status", sdk.ToolDef{
|
||||
Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleStatus)
|
||||
|
||||
addr, _ := s.Settings().Get("listen")
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
if err := p.startServer(addrStr); err != nil {
|
||||
log.Printf("[%s] start ACP server: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[%s] started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopServer()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) stopServer() {
|
||||
p.srvMu.Lock()
|
||||
defer p.srvMu.Unlock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
p.server = nil
|
||||
p.serverID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/session", p.handleSession)
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %v", addr, err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
addrStr := listener.Addr().String()
|
||||
|
||||
p.srvMu.Lock()
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
p.server = srv
|
||||
p.serverID = addrStr
|
||||
p.srvMu.Unlock()
|
||||
|
||||
go func() {
|
||||
log.Printf("[%s] ACP server on %s", p.name, addrStr)
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
p.handleSessionPost(w, r)
|
||||
case "GET":
|
||||
p.handleSessionSSE(w, r)
|
||||
default:
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionPost 处理 JSON-RPC:session/new 与 session/update
|
||||
func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Request *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"request,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Final bool `json:"final,omitempty"`
|
||||
} `json:"params,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "session/new":
|
||||
text := ""
|
||||
if req.Params.Request != nil {
|
||||
text = strings.TrimSpace(req.Params.Request.Text)
|
||||
}
|
||||
if text == "" {
|
||||
http.Error(w, "request.text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sid := fmt.Sprintf("session_%d", time.Now().UnixNano())
|
||||
p.mu.Lock()
|
||||
p.sessions[sid] = &sessionState{ID: sid}
|
||||
p.mu.Unlock()
|
||||
|
||||
if p.sdk != nil {
|
||||
p.sdk.InjectInterruptText(p.name, "acp",
|
||||
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, text))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"session": map[string]interface{}{"id": sid},
|
||||
},
|
||||
})
|
||||
|
||||
case "session/update":
|
||||
sid := req.Params.SessionID
|
||||
p.mu.Lock()
|
||||
st := p.sessions[sid]
|
||||
if st != nil && req.Params.Final {
|
||||
st.Replying = append(st.Replying, map[string]interface{}{
|
||||
"type": "reply", "text": "done",
|
||||
})
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"final": true},
|
||||
})
|
||||
|
||||
case "session/cancel":
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, req.Params.SessionID)
|
||||
p.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"canceled": true},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionSSE 提供 SSE 事件流订阅
|
||||
func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) {
|
||||
sid := r.URL.Query().Get("id")
|
||||
if sid == "" {
|
||||
http.Error(w, "id query param required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
st := p.sessions[sid]
|
||||
p.mu.RUnlock()
|
||||
if st == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
p.mu.RLock()
|
||||
replies := append([]map[string]interface{}{}, st.Replying...)
|
||||
p.mu.RUnlock()
|
||||
for _, rep := range replies {
|
||||
data, _ := json.Marshal(rep)
|
||||
fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data)
|
||||
fl.Flush()
|
||||
}
|
||||
p.mu.Lock()
|
||||
st.Replying = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Outbound:ACP 客户端 ----
|
||||
|
||||
// parseRPCBody 兼容 JSON 与 SSE 两种响应体
|
||||
func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) {
|
||||
if strings.Contains(ct, "text/event-stream") {
|
||||
sc := bufio.NewScanner(bytes.NewReader(body))
|
||||
var last string
|
||||
for sc.Scan() {
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data != "" && data != "[DONE]" {
|
||||
last = data
|
||||
}
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200))
|
||||
}
|
||||
body = []byte(last)
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300))
|
||||
}
|
||||
return &raw, nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) {
|
||||
serverURL, _ := args["server_url"].(string)
|
||||
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
|
||||
if serverURL == "" {
|
||||
return map[string]interface{}{"error": "server_url 不能为空"}, nil
|
||||
}
|
||||
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
|
||||
serverURL = "http://" + serverURL
|
||||
}
|
||||
prompt, _ := args["prompt"].(string)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
||||
}
|
||||
timeoutSec := 120
|
||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||
timeoutSec = int(v)
|
||||
}
|
||||
|
||||
endpoint := serverURL + "/api/session"
|
||||
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||
|
||||
newBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
"method": "session/new",
|
||||
"params": map[string]interface{}{
|
||||
"request": map[string]interface{}{"text": prompt},
|
||||
},
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil
|
||||
}
|
||||
|
||||
raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
var rpcResp struct {
|
||||
Result *struct {
|
||||
Session *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"session,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
} `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(*raw, &rpcResp); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
if rpcResp.Error != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil
|
||||
}
|
||||
if rpcResp.Result == nil {
|
||||
return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
|
||||
// 兼容两种协议:
|
||||
// A) 标准/SSE 型(opencode、本插件服务端):result.session.id,回复经 SSE 事件流
|
||||
// B) 同步 JSON 型(pi bridge):result.sessionId + result.reply
|
||||
if rpcResp.Result.Reply != "" {
|
||||
return map[string]interface{}{
|
||||
"session_id": rpcResp.Result.SessionID,
|
||||
"status": "completed",
|
||||
"reply": rpcResp.Result.Reply,
|
||||
}, nil
|
||||
}
|
||||
if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" {
|
||||
return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil
|
||||
}
|
||||
sid := rpcResp.Result.Session.ID
|
||||
|
||||
replyText := p.readSSEReply(endpoint, sid, client, timeoutSec)
|
||||
|
||||
return map[string]interface{}{
|
||||
"session_id": sid,
|
||||
"status": "completed",
|
||||
"reply": replyText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本
|
||||
func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string {
|
||||
sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid)
|
||||
req, _ := http.NewRequest("GET", sseURL, nil)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(SSE 读取失败: %v)", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bb, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
for sc.Scan() {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var evt struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Message *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"message,omitempty"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &evt) == nil {
|
||||
text := evt.Text
|
||||
if evt.Message != nil && evt.Message.Text != "" {
|
||||
text = evt.Message.Text
|
||||
}
|
||||
if text != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "(未收到回复)"
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ---- Management ----
|
||||
|
||||
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||
listen, _ := args["listen"].(string)
|
||||
listen = strings.TrimSpace(listen)
|
||||
|
||||
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||
}
|
||||
|
||||
if listen == "" || listen == "off" || listen == "disabled" {
|
||||
p.stopServer()
|
||||
return "ACP HTTP 服务已禁用", nil
|
||||
}
|
||||
|
||||
if err := p.startServer(listen); err != nil {
|
||||
return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
addr, _ := p.sdk.Settings().Get("listen")
|
||||
addrStr, _ := addr.(string)
|
||||
|
||||
p.srvMu.Lock()
|
||||
serverRunning := p.server != nil
|
||||
listening := p.serverID
|
||||
p.srvMu.Unlock()
|
||||
|
||||
p.mu.RLock()
|
||||
n := len(p.sessions)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if !serverRunning {
|
||||
listening = "未运行"
|
||||
}
|
||||
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d",
|
||||
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
# testplugin
|
||||
# ai_image
|
||||
|
||||
testplugin plugin
|
||||
ai_image plugin
|
||||
|
||||
## Build
|
||||
|
||||
7
example/ai_image/go.mod
Normal file
7
example/ai_image/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module ai_image
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/ai_image/main.go
Normal file
11
example/ai_image/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
15
example/ai_image/plg.json
Normal file
15
example/ai_image/plg.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "ai_image",
|
||||
"name_zh": "AI绘图",
|
||||
"name_en": "AI Image",
|
||||
"version": "1.0.0",
|
||||
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["ai", "image", "draw", "generate"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
342
example/ai_image/plugin.go
Normal file
342
example/ai_image/plugin.go
Normal file
@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
client *http.Client
|
||||
apiKey string
|
||||
provider string
|
||||
model string
|
||||
size string
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, def T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return def
|
||||
}
|
||||
switch any(def).(type) {
|
||||
case string:
|
||||
if sv, ok := v.(string); ok {
|
||||
return any(sv).(T)
|
||||
}
|
||||
case int64:
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return any(int64(n)).(T)
|
||||
case int64:
|
||||
return any(n).(T)
|
||||
case string:
|
||||
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
|
||||
return any(i).(T)
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return any(n).(T)
|
||||
case int64:
|
||||
return any(float64(n)).(T)
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||||
return any(f).(T)
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getArg[T string | int64 | float64](args map[string]interface{}, key string, def T) T {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def
|
||||
}
|
||||
switch any(def).(type) {
|
||||
case string:
|
||||
if s, ok := v.(string); ok {
|
||||
return any(s).(T)
|
||||
}
|
||||
case int64:
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return any(int64(n)).(T)
|
||||
case int64:
|
||||
return any(n).(T)
|
||||
case string:
|
||||
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
|
||||
return any(i).(T)
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return any(n).(T)
|
||||
case int64:
|
||||
return any(float64(n)).(T)
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||||
return any(f).(T)
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.client = &http.Client{Timeout: 120 * time.Second}
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "api_key", Default: "", Type: "string",
|
||||
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
||||
Category: "ai_image", Secret: true,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "provider", Default: "openai", Type: "string",
|
||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "model", Default: "dall-e-3", Type: "string",
|
||||
DisplayName: "Model", Description: "Model name (dall-e-3, sd-xl, etc.)",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "size", Default: "1024x1024", Type: "string",
|
||||
DisplayName: "Size", Description: "Default image size (1024x1024, 1024x1792, 1792x1024)",
|
||||
Category: "ai_image",
|
||||
})
|
||||
|
||||
p.apiKey = getSetting(s.Settings(), "api_key", "")
|
||||
p.provider = getSetting(s.Settings(), "provider", "openai")
|
||||
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"prompt": map[string]interface{}{"type": "string", "description": "Text description of the image to generate"},
|
||||
"size": map[string]interface{}{"type": "string", "description": "Image size (1024x1024, 1024x1792, 1792x1024), default from config"},
|
||||
"model": map[string]interface{}{"type": "string", "description": "Model override (dall-e-3, dall-e-2)"},
|
||||
"n": map[string]interface{}{"type": "integer", "description": "Number of images to generate (1-10), default 1"},
|
||||
},
|
||||
"required": []string{"prompt"},
|
||||
},
|
||||
}, p.handleGenerate)
|
||||
|
||||
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
type openAIReq struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
N int `json:"n"`
|
||||
Size string `json:"size"`
|
||||
ResponseFormat string `json:"response_format"`
|
||||
}
|
||||
|
||||
type openAIResp struct {
|
||||
Created int64 `json:"created"`
|
||||
Data []struct {
|
||||
RevisedPrompt string `json:"revised_prompt"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error) {
|
||||
prompt := getArg(args, "prompt", "")
|
||||
if prompt == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "prompt is required"}, nil
|
||||
}
|
||||
|
||||
key := getSetting(p.sdk.Settings(), "api_key", p.apiKey)
|
||||
if key == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "API key not configured. Set plugin.ai_image.api_key via CLI."}, nil
|
||||
}
|
||||
|
||||
provider := getSetting(p.sdk.Settings(), "provider", p.provider)
|
||||
model := getArg(args, "model", getSetting(p.sdk.Settings(), "model", p.model))
|
||||
size := getArg(args, "size", getSetting(p.sdk.Settings(), "size", p.size))
|
||||
n := getArg(args, "n", int64(1))
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > 10 {
|
||||
n = 10
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
return p.generateOpenAI(prompt, model, size, int(n), key)
|
||||
case "stability":
|
||||
return p.generateStability(prompt, model, size, int(n), key)
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "Unknown provider: " + provider + ". Supported: openai, stability"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||||
body := openAIReq{
|
||||
Model: model,
|
||||
Prompt: prompt,
|
||||
N: n,
|
||||
Size: size,
|
||||
ResponseFormat: "url",
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
var result openAIResp
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "API error: " + result.Error.Message}, nil
|
||||
}
|
||||
|
||||
if len(result.Data) == 0 {
|
||||
return map[string]interface{}{"isError": true, "content": "No images returned"}, nil
|
||||
}
|
||||
|
||||
urls := make([]string, len(result.Data))
|
||||
for i, d := range result.Data {
|
||||
urls[i] = d.URL
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
|
||||
"images": urls,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type stabilityReq struct {
|
||||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Samples int `json:"samples"`
|
||||
}
|
||||
|
||||
type stabilityPrompt struct {
|
||||
Text string `json:"text"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
type stabilityArtifact struct {
|
||||
Base64 string `json:"base64"`
|
||||
Seed int `json:"seed"`
|
||||
}
|
||||
|
||||
type stabilityResp struct {
|
||||
Artifacts []stabilityArtifact `json:"artifacts"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||||
width, height := 1024, 1024
|
||||
if parts := strings.Split(size, "x"); len(parts) == 2 {
|
||||
if w, err := strconv.Atoi(parts[0]); err == nil {
|
||||
width = w
|
||||
}
|
||||
if h, err := strconv.Atoi(parts[1]); err == nil {
|
||||
height = h
|
||||
}
|
||||
}
|
||||
|
||||
body := stabilityReq{
|
||||
TextPrompts: []stabilityPrompt{{Text: prompt, Weight: 1.0}},
|
||||
Width: width,
|
||||
Height: height,
|
||||
Samples: n,
|
||||
}
|
||||
|
||||
apiURL := "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image"
|
||||
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}, nil
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
var result stabilityResp
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
|
||||
}
|
||||
|
||||
if len(result.Artifacts) == 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "No images returned"
|
||||
}
|
||||
return map[string]interface{}{"isError": true, "content": msg}, nil
|
||||
}
|
||||
|
||||
urls := make([]string, len(result.Artifacts))
|
||||
for i, a := range result.Artifacts {
|
||||
urls[i] = "data:image/png;base64," + a.Base64
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")),
|
||||
"images": urls,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
}, nil
|
||||
}
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
11
example/bili/main.go
Normal file
11
example/bili/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["bili", "video", "download"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -8,13 +8,15 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
proxy string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -25,11 +27,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
tp := p.name + "_"
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin." + p.name + ".output_dir", Default: "/tmp/bili_videos",
|
||||
Key: "output_dir", Default: "/tmp/bili_videos",
|
||||
Type: "string", DisplayName: "下载目录",
|
||||
Description: "B站视频下载后的保存目录",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "proxy", Default: "",
|
||||
Type: "string", DisplayName: "HTTP 代理",
|
||||
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
|
||||
Category: p.name,
|
||||
})
|
||||
if v, _ := s.Settings().Get("proxy"); v != nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.proxy = str
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||
Name: tp + "video",
|
||||
@ -43,6 +56,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Content string }
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
|
||||
return r.Content
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleBiliVideo)
|
||||
return nil
|
||||
}
|
||||
@ -81,7 +101,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
|
||||
outputDir := "/tmp/bili_videos"
|
||||
if p.sdk != nil {
|
||||
if v, _ := p.sdk.Settings().Get("plugin." + p.name + ".output_dir"); v != nil {
|
||||
if v, _ := p.sdk.Settings().Get("output_dir"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
outputDir = s
|
||||
}
|
||||
@ -94,7 +114,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
cmd.Env = proxyEnv(p.proxy)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
@ -164,12 +184,17 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(taskDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir task dir: %w", err)
|
||||
}
|
||||
|
||||
dlArgs := []string{
|
||||
"--no-warnings",
|
||||
"--socket-timeout", "30",
|
||||
"--retries", "3",
|
||||
"--fragment-retries", "3",
|
||||
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
||||
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
|
||||
"--no-overwrites",
|
||||
}
|
||||
if format != "" {
|
||||
@ -177,7 +202,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
}
|
||||
dlArgs = append(dlArgs, url)
|
||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
||||
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
cmd2.Env = proxyEnv(p.proxy)
|
||||
var dlOut bytes.Buffer
|
||||
cmd2.Stdout = &dlOut
|
||||
cmd2.Stderr = &dlOut
|
||||
@ -185,9 +210,18 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(outputDir)
|
||||
var newest string
|
||||
var newestTime int64
|
||||
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
|
||||
for _, f := range parts {
|
||||
os.Remove(f)
|
||||
}
|
||||
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
|
||||
for _, f := range residuals {
|
||||
os.Remove(f)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(taskDir)
|
||||
var mainFile string
|
||||
var mainSize int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
@ -196,30 +230,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
||||
if fi == nil {
|
||||
continue
|
||||
}
|
||||
t := fi.ModTime().Unix()
|
||||
if t > newestTime {
|
||||
newestTime = t
|
||||
newest = e.Name()
|
||||
if fi.Size() > mainSize {
|
||||
mainSize = fi.Size()
|
||||
mainFile = e.Name()
|
||||
}
|
||||
}
|
||||
if newest == "" {
|
||||
if mainFile == "" {
|
||||
return map[string]interface{}{
|
||||
"content": "下载完成,但未找到视频文件",
|
||||
}, nil
|
||||
}
|
||||
dlPath := filepath.Join(outputDir, newest)
|
||||
fi, _ := os.Stat(dlPath)
|
||||
var fileSize int64
|
||||
if fi != nil {
|
||||
fileSize = fi.Size()
|
||||
}
|
||||
dlPath := filepath.Join(taskDir, mainFile)
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
|
||||
"file": dlPath,
|
||||
"filename": newest,
|
||||
"filename": mainFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func proxyEnv(proxy string) []string {
|
||||
env := os.Environ()
|
||||
if proxy != "" {
|
||||
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
@ -229,6 +265,6 @@ func contains(slice []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
/* 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
|
||||
@ -2,6 +2,25 @@ module browser
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
require (
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
github.com/chromedp/chromedp v0.9.5
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
|
||||
github.com/chromedp/sysutil v1.0.0
|
||||
github.com/gobwas/httphead v0.1.0
|
||||
github.com/gobwas/pool v0.2.1
|
||||
github.com/gobwas/ws v1.3.2
|
||||
github.com/josharian/intern v1.0.0
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80
|
||||
github.com/mailru/easyjson v0.7.7
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde
|
||||
golang.org/x/sys v0.16.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
23
example/browser/go.sum
Normal file
23
example/browser/go.sum
Normal file
@ -0,0 +1,23 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
||||
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
11
example/browser/main.go
Normal file
11
example/browser/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "browser",
|
||||
"name_zh": "浏览器",
|
||||
"name_en": "browser",
|
||||
"version": "1.0.0",
|
||||
"description": "网络资源搜索与获取:搜索引擎查询(browser_search)、网页抓取(browser_fetch,SSRF防护)、无头浏览器渲染(browser_render)",
|
||||
"name_en": "Browser",
|
||||
"version": "2.0.0",
|
||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "search", "fetch", "browser"],
|
||||
"targets": "linux/amd64"
|
||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
13
example/calendar/README.md
Normal file
13
example/calendar/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# calendar
|
||||
|
||||
calendar plugin
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
7
example/calendar/go.mod
Normal file
7
example/calendar/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module calendar
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/calendar/main.go
Normal file
11
example/calendar/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
15
example/calendar/plg.json
Normal file
15
example/calendar/plg.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "calendar",
|
||||
"name_zh": "日历",
|
||||
"name_en": "Calendar",
|
||||
"version": "1.0.0",
|
||||
"description": "日历事件管理,支持提醒和重复事件",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
1179
example/calendar/plugin.go
Normal file
1179
example/calendar/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
11
example/editdoc/main.go
Normal file
11
example/editdoc/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["editdoc", "office", "document"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -4,15 +4,19 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
scriptPath string
|
||||
venvPython string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -20,9 +24,34 @@ func (p *Plugin) Name() string { return p.name }
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "script_path", Default: "", Type: "string",
|
||||
DisplayName: "编辑脚本路径",
|
||||
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "venv_python", Default: "", Type: "string",
|
||||
DisplayName: "venv Python 解释器",
|
||||
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
if v, err := s.Settings().Get("script_path"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.scriptPath = str
|
||||
}
|
||||
}
|
||||
if v, err := s.Settings().Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = str
|
||||
}
|
||||
}
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -79,19 +108,24 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
|
||||
}
|
||||
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||
|
||||
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py"
|
||||
scriptPath := p.scriptPath
|
||||
if scriptPath == "" {
|
||||
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
|
||||
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
|
||||
}
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath)
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
|
||||
}
|
||||
|
||||
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3"
|
||||
pythonBin := "python3"
|
||||
if _, err := os.Stat(venvPython); err == nil {
|
||||
pythonBin = venvPython
|
||||
if p.venvPython == "" {
|
||||
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
|
||||
}
|
||||
if _, err := os.Stat(p.venvPython); err != nil {
|
||||
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("edit document: %w", err)
|
||||
@ -124,6 +158,6 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
|
||||
}
|
||||
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
7
example/files/go.mod
Normal file
7
example/files/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module files
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/files/main.go
Normal file
11
example/files/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["files", "filesystem"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@ -25,25 +26,40 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.files.dir",
|
||||
Default: "/",
|
||||
Key: "dir",
|
||||
Default: "",
|
||||
Type: "string",
|
||||
DisplayName: "文件系统根目录",
|
||||
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||
Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox),不建议设为 /",
|
||||
Category: "files",
|
||||
})
|
||||
|
||||
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||
dir := getSetting[string](s.Settings(), "dir", "")
|
||||
if strings.HasPrefix(dir, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, dir[2:])
|
||||
}
|
||||
if dir == "" {
|
||||
dataDir, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
base := "."
|
||||
if err == nil {
|
||||
if ds, ok := dataDir.(string); ok && ds != "" {
|
||||
base = ds
|
||||
}
|
||||
}
|
||||
dir = filepath.Join(base, "files_sandbox")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve files.dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir files.dir: %w", err)
|
||||
}
|
||||
if real, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = real
|
||||
}
|
||||
p.filesDir = abs
|
||||
os.MkdirAll(p.filesDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
@ -59,6 +75,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
NoMemory: false,
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Content string }
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
|
||||
return r.Content
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleRead)
|
||||
|
||||
s.RegisterTool(tp+"write", sdk.ToolDef{
|
||||
@ -134,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
base := filepath.Clean(p.filesDir)
|
||||
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||
if !withinSandbox(base, abs) {
|
||||
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||
}
|
||||
return abs, nil
|
||||
real, err := evalReal(base, abs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !withinSandbox(base, real) {
|
||||
return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
func withinSandbox(base, abs string) bool {
|
||||
if base == "/" {
|
||||
return true
|
||||
}
|
||||
return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func evalReal(base, abs string) (string, error) {
|
||||
existing := abs
|
||||
var tail []string
|
||||
for {
|
||||
real, err := filepath.EvalSymlinks(existing)
|
||||
if err == nil {
|
||||
full := real
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
full = filepath.Join(full, tail[i])
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
if link, lerr := os.Readlink(existing); lerr == nil {
|
||||
target := link
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(existing), target)
|
||||
}
|
||||
if t, aerr := filepath.Abs(target); aerr == nil {
|
||||
target = filepath.Clean(t)
|
||||
}
|
||||
if !withinSandbox(base, target) {
|
||||
return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs)
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(existing)
|
||||
if parent == existing {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
tail = append(tail, filepath.Base(existing))
|
||||
existing = parent
|
||||
}
|
||||
}
|
||||
|
||||
// handleRead implements the read tool.
|
||||
@ -478,6 +552,6 @@ func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||
return val
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
25
example/luademo/README.md
Normal file
25
example/luademo/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# luademo
|
||||
|
||||
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
|
||||
|
||||
- **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤)
|
||||
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
||||
- **通道**:`register_output_channel` / `register_input_channel`(def 支持 no_memory/cleaner)
|
||||
- **数据类 API**:`sdk.memory.*`、`sdk.doc.*`、`sdk.knowledge.*`、`sdk.text_memory.*`、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`
|
||||
- **其他**:`register_api`、`set_auto_restart`
|
||||
|
||||
## 本地独立测试
|
||||
|
||||
```bash
|
||||
lua main.lua # 使用 sdk.lua mock,不依赖内核
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
通过插件管理 HTTP API 上传 `.hmap` 包,或解压到 `<data>/plugins/luademo/` 后重启内核。
|
||||
101
example/luademo/main.lua
Normal file
101
example/luademo/main.lua
Normal file
@ -0,0 +1,101 @@
|
||||
-- luademo plugin — 展示 v0.8.0 Lua SDK 全部能力
|
||||
-- 运行环境:内核注入真实实现;lua main.lua 可用 sdk.lua mock 独立测试
|
||||
local plugin = { name = "luademo" }
|
||||
|
||||
function plugin.start(sdk)
|
||||
sdk.log("info", "luademo starting...")
|
||||
|
||||
-- 注册配置项(WebUI 可展示)
|
||||
sdk.settings.register_def({
|
||||
key = "plugin.luademo.greeting",
|
||||
default = "Hello",
|
||||
type = "string",
|
||||
display_name = "Greeting",
|
||||
description = "Greeting prefix for the hello tool",
|
||||
category = "luademo",
|
||||
})
|
||||
|
||||
-- 注册工具:no_memory(输出跳过记忆计算)+ cleaner(计算层过滤函数)
|
||||
sdk.register_tool("luademo_hello", {
|
||||
description = "A hello world tool with no_memory and cleaner",
|
||||
parameters = { type = "object", properties = {} },
|
||||
no_memory = true,
|
||||
cleaner = function(text) return "CLEANED:" .. text end,
|
||||
}, function(args)
|
||||
local prefix, err = sdk.settings.get_core("plugin.luademo.greeting")
|
||||
if err ~= nil then prefix = "Hello" end
|
||||
return { content = (prefix or "Hello") .. " from luademo plugin!" }
|
||||
end)
|
||||
|
||||
-- 注册工具:数据类 API 巡检(memory/doc/knowledge/text_memory/llm/settings/social)
|
||||
sdk.register_tool("luademo_probe", {
|
||||
description = "Exercise every aligned data API and return combined results",
|
||||
parameters = { type = "object", properties = {} },
|
||||
no_memory = true,
|
||||
}, function(args)
|
||||
local res = {}
|
||||
|
||||
local ok, err = sdk.memory.commit({ { subject = "demo", relation = "uses", object = "lua" } })
|
||||
res.memory_commit = { ok = ok, err = err }
|
||||
local recalled, rerr = sdk.memory.recall("demo", 1)
|
||||
res.memory_recall = { result = recalled, err = rerr }
|
||||
|
||||
ok, err = sdk.doc.insert({ id = "demo-1", title = "lua demo doc", content = "hello lua world" })
|
||||
res.doc_insert = { ok = ok, err = err }
|
||||
local docs, derr = sdk.doc.query("lua", 2)
|
||||
res.doc_query = { result = docs, err = derr }
|
||||
|
||||
ok, err = sdk.knowledge.add("luademo", "lua knowledge entry")
|
||||
res.knowledge_add = { ok = ok, err = err }
|
||||
local entries, kerr = sdk.knowledge.search("luademo", 2)
|
||||
res.knowledge_search = { result = entries, err = kerr }
|
||||
|
||||
ok, err = sdk.text_memory.append({ role = "tool", content = "luademo probe ran", channel = "luademo" })
|
||||
res.text_memory = { ok = ok, err = err }
|
||||
|
||||
local sources, serr = sdk.llm.list_sources()
|
||||
res.llm_sources = { result = sources, err = serr }
|
||||
|
||||
local v, verr = sdk.settings.get_core("agent.name")
|
||||
res.settings_get_core = { result = v, err = verr }
|
||||
local defs, defserr = sdk.settings.defs("plugin.luademo")
|
||||
res.settings_defs = { result = defs, err = defserr }
|
||||
|
||||
local persons, perr = sdk.social.list_persons()
|
||||
res.social_persons = { result = persons, err = perr }
|
||||
|
||||
return { content = res }
|
||||
end)
|
||||
|
||||
-- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发)
|
||||
sdk.register_stage("before_toolcall", function(ctx)
|
||||
local calls = ctx.tool_calls or {}
|
||||
if calls[1] then
|
||||
sdk.log("info", "luademo stage before_toolcall: tool=" .. tostring(calls[1].name))
|
||||
end
|
||||
return nil
|
||||
end, "own_tools")
|
||||
|
||||
-- 阶段钩子:全局作用域
|
||||
sdk.register_stage("pre_action", function(ctx)
|
||||
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
|
||||
return nil
|
||||
end)
|
||||
|
||||
-- 输出通道:路由输出到外部渠道(def 支持 no_memory/cleaner)
|
||||
sdk.register_output_channel("luademo_out", 0, "luademo push channel",
|
||||
{ no_memory = true, cleaner = function(t) return "OCLEANED:" .. t end },
|
||||
function(args) return { content = "out-channel ack" } end)
|
||||
|
||||
-- 输入通道
|
||||
sdk.register_input_channel("luademo_in", { no_memory = true })
|
||||
|
||||
-- 其他 API
|
||||
sdk.register_api("luademo.ping")
|
||||
sdk.set_auto_restart(true)
|
||||
|
||||
sdk.log("info", "luademo started")
|
||||
end
|
||||
|
||||
function plugin.stop() sdk.log("info", "luademo stopped") end
|
||||
return plugin
|
||||
11
example/luademo/plg.json
Normal file
11
example/luademo/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "luademo",
|
||||
"name_zh": "Lua 全功能示例",
|
||||
"name_en": "Lua Demo",
|
||||
"version": "0.1.0",
|
||||
"description": "Lua 插件全功能示例:工具(no_memory/cleaner) + 阶段钩子 + 通道 + 数据类 API",
|
||||
"author": "HomeAgent",
|
||||
"entry": "main.lua",
|
||||
"tags": ["luademo"],
|
||||
"targets": "lua"
|
||||
}
|
||||
67
example/luademo/sdk.lua
Normal file
67
example/luademo/sdk.lua
Normal file
@ -0,0 +1,67 @@
|
||||
-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||
sdk = {}
|
||||
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
||||
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
||||
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
|
||||
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
||||
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
|
||||
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
|
||||
function sdk.get_setting(key) return nil end
|
||||
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
||||
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
||||
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
||||
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
||||
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
|
||||
sdk.memory = {}
|
||||
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||
function sdk.memory.commit(triples) return nil end
|
||||
function sdk.memory.introspect() return {} end
|
||||
function sdk.memory.merge(source, target) return 0 end
|
||||
function sdk.memory.purge(criteria, hard) return 0 end
|
||||
sdk.doc = {}
|
||||
function sdk.doc.query(text, top_k) return {} end
|
||||
function sdk.doc.insert(doc) return nil end
|
||||
function sdk.doc.remove(id) return nil end
|
||||
function sdk.doc.stats() return {} end
|
||||
sdk.knowledge = {}
|
||||
function sdk.knowledge.search(query, limit) return {} end
|
||||
function sdk.knowledge.add(tag, content) return nil end
|
||||
function sdk.knowledge.list() return {} end
|
||||
sdk.text_memory = {}
|
||||
function sdk.text_memory.append(evt) return nil end
|
||||
sdk.llm = {}
|
||||
function sdk.llm.list_sources() return {} end
|
||||
function sdk.llm.set_source(name) return nil end
|
||||
function sdk.llm.current_source() return nil end
|
||||
sdk.social = {}
|
||||
function sdk.social.get_person(name) return {} end
|
||||
function sdk.social.get_network(name, depth) return {} end
|
||||
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||
function sdk.social.get_relations(name) return {} end
|
||||
function sdk.social.list_persons() return {} end
|
||||
sdk.settings = {}
|
||||
function sdk.settings.get_core(key) return nil end
|
||||
function sdk.settings.set_core(key, value) return nil end
|
||||
function sdk.settings.list_core(prefix) return {} end
|
||||
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||
function sdk.settings.list(prefix) return {} end
|
||||
function sdk.settings.register_def(def) return nil end
|
||||
function sdk.settings.defs(prefix) return {} end
|
||||
function sdk.settings.dump() return {} end
|
||||
function sdk.settings.plugins() return {} end
|
||||
sdk.json = {}
|
||||
function sdk.json.encode(val)
|
||||
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
||||
elseif type(val) == "table" then local parts, i = {}, 1
|
||||
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
||||
return "{" .. table.concat(parts, ",") .. "}" end
|
||||
return "null"
|
||||
end
|
||||
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||
sdk.http = {}
|
||||
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
return sdk
|
||||
@ -4,4 +4,4 @@ go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
11
example/memo/main.go
Normal file
11
example/memo/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "memo",
|
||||
"name_zh": "备忘录",
|
||||
"name_en": "Memo/Notes",
|
||||
"name_en": "Memo",
|
||||
"version": "1.0.0",
|
||||
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
|
||||
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["memo", "todo", "notes"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -13,20 +13,31 @@ import (
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Memo struct {
|
||||
// Todo 待办条目:会被主动提醒
|
||||
type Todo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// Memo 备忘录条目:纯记事,不主动提醒
|
||||
type Memo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
todos []Todo
|
||||
nextTID int64
|
||||
memos []Memo
|
||||
nextID int64
|
||||
filePath string
|
||||
nextMID int64
|
||||
todoPath string
|
||||
memoPath string
|
||||
stopCh chan struct{}
|
||||
tp string
|
||||
}
|
||||
@ -37,70 +48,154 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.tp = p.name + "_"
|
||||
p.stopCh = make(chan struct{})
|
||||
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
||||
p.load()
|
||||
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
|
||||
}
|
||||
p.todoPath = filepath.Join(dir, "todos.json")
|
||||
p.memoPath = filepath.Join(dir, "memos.json")
|
||||
p.loadTodos()
|
||||
p.loadMemos()
|
||||
|
||||
s.RegisterTool(p.tp+"create", sdk.ToolDef{
|
||||
Name: p.tp + "create",
|
||||
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
|
||||
// 卸载(删除)时清理数据文件;重载不触发
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
// ── 待办(会被主动提醒)──
|
||||
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
|
||||
Name: p.tp + "todo_add",
|
||||
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleCreate)
|
||||
}, p.handleTodoAdd)
|
||||
|
||||
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
|
||||
Name: p.tp + "complete",
|
||||
Description: "将指定ID的备忘标记为已完成。",
|
||||
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
|
||||
Name: p.tp + "todo_complete",
|
||||
Description: "将指定ID的待办标记为已完成(不再提醒)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
|
||||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleComplete)
|
||||
}, p.handleTodoComplete)
|
||||
|
||||
s.RegisterTool(p.tp+"list", sdk.ToolDef{
|
||||
Name: p.tp + "list",
|
||||
Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。",
|
||||
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
|
||||
Name: p.tp + "todo_list",
|
||||
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
}, p.handleTodoList)
|
||||
|
||||
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
|
||||
Name: p.tp + "todo_delete",
|
||||
Description: "删除指定ID的待办事项(包括已完成的)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleTodoDelete)
|
||||
|
||||
// ── 备忘(纯记事,不提醒)──
|
||||
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
|
||||
Name: p.tp + "memo_create",
|
||||
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleMemoCreate)
|
||||
|
||||
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
|
||||
Name: p.tp + "memo_list",
|
||||
Description: "列出所有备忘录,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleMemoList)
|
||||
|
||||
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
|
||||
Name: p.tp + "memo_delete",
|
||||
Description: "删除指定ID的备忘录。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleMemoDelete)
|
||||
|
||||
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
|
||||
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||
|
||||
go p.periodicCheck()
|
||||
|
||||
log.Printf("[%s] started, path=%s", p.name, p.filePath)
|
||||
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.save()
|
||||
p.saveTodos()
|
||||
p.saveMemos()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) load() {
|
||||
func (p *Plugin) loadTodos() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.filePath)
|
||||
data, err := os.ReadFile(p.todoPath)
|
||||
if err != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
p.todos = []Todo{}
|
||||
p.nextTID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
Todos []Todo `json:"todos"`
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.todos = []Todo{}
|
||||
p.nextTID = 1
|
||||
return
|
||||
}
|
||||
p.todos = store.Todos
|
||||
p.nextTID = store.NextID
|
||||
if p.todos == nil {
|
||||
p.todos = []Todo{}
|
||||
}
|
||||
if p.nextTID < 1 {
|
||||
p.nextTID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) loadMemos() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.memoPath)
|
||||
if err != nil {
|
||||
p.memos = []Memo{}
|
||||
p.nextMID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
@ -108,66 +203,82 @@ func (p *Plugin) load() {
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
p.memos = []Memo{}
|
||||
p.nextMID = 1
|
||||
return
|
||||
}
|
||||
p.memos = store.Memos
|
||||
p.nextID = store.NextID
|
||||
p.nextMID = store.NextID
|
||||
if p.memos == nil {
|
||||
p.memos = []Memo{}
|
||||
}
|
||||
if p.nextID < 1 {
|
||||
p.nextID = 1
|
||||
if p.nextMID < 1 {
|
||||
p.nextMID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) save() {
|
||||
func (p *Plugin) saveTodos() {
|
||||
p.mu.RLock()
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextID,
|
||||
"todos": p.todos,
|
||||
"next_id": p.nextTID,
|
||||
}, "", " ")
|
||||
os.WriteFile(p.filePath, data, 0644)
|
||||
p.mu.RUnlock()
|
||||
os.WriteFile(p.todoPath, data, 0644)
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingCount() int {
|
||||
func (p *Plugin) saveMemos() {
|
||||
p.mu.RLock()
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextMID,
|
||||
}, "", " ")
|
||||
p.mu.RUnlock()
|
||||
os.WriteFile(p.memoPath, data, 0644)
|
||||
}
|
||||
|
||||
// ── 待办:未完成计数与提醒 ──
|
||||
|
||||
func (p *Plugin) pendingTodoCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
n := 0
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
for _, t := range p.todos {
|
||||
if !t.Done {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingMemos() []Memo {
|
||||
func (p *Plugin) pendingTodos() []Todo {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var out []Memo
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
out = append(out, m)
|
||||
var out []Todo
|
||||
for _, t := range p.todos {
|
||||
if !t.Done {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
|
||||
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||
n := p.pendingCount()
|
||||
n := p.pendingTodoCount()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx.Lock()
|
||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
||||
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
|
||||
})
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
|
||||
func (p *Plugin) periodicCheck() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@ -176,19 +287,124 @@ func (p *Plugin) periodicCheck() {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
n := p.pendingCount()
|
||||
n := p.pendingTodoCount()
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if p.sdk != nil {
|
||||
p.sdk.InjectInterruptText(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
|
||||
// ── 待办工具 ──
|
||||
|
||||
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
todo := Todo{
|
||||
ID: p.nextTID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextTID++
|
||||
p.todos = append(p.todos, todo)
|
||||
p.mu.Unlock()
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
|
||||
"id": todo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.todos {
|
||||
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
|
||||
p.todos[i].Done = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
|
||||
todos := p.pendingTodos()
|
||||
if len(todos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的待办",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, t := range todos {
|
||||
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
"count": len(todos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.todos {
|
||||
if p.todos[i].ID == int64(id) {
|
||||
p.todos = append(p.todos[:i], p.todos[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.saveTodos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 备忘工具 ──
|
||||
|
||||
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
@ -196,23 +412,22 @@ func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
p.mu.Lock()
|
||||
memo := Memo{
|
||||
ID: p.nextID,
|
||||
ID: p.nextMID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextID++
|
||||
p.nextMID++
|
||||
p.memos = append(p.memos, memo)
|
||||
p.mu.Unlock()
|
||||
p.save()
|
||||
p.saveMemos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
|
||||
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
|
||||
"id": memo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
|
||||
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
@ -221,8 +436,8 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.memos {
|
||||
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
|
||||
p.memos[i].Done = true
|
||||
if p.memos[i].ID == int64(id) {
|
||||
p.memos = append(p.memos[:i], p.memos[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@ -230,30 +445,33 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
|
||||
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.save()
|
||||
p.saveMemos()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
|
||||
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
memos := p.pendingMemos()
|
||||
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.RLock()
|
||||
memos := append([]Memo{}, p.memos...)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if len(memos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的备忘",
|
||||
"content": "暂无备忘录",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, m := range memos {
|
||||
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
@ -269,6 +487,16 @@ func errorResult(msg string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
// cleanupData 卸载时清理数据文件(待办 + 备忘)
|
||||
func (p *Plugin) cleanupData() {
|
||||
if p.todoPath != "" {
|
||||
os.Remove(p.todoPath)
|
||||
}
|
||||
if p.memoPath != "" {
|
||||
os.Remove(p.memoPath)
|
||||
}
|
||||
}
|
||||
|
||||
7
example/music/go.mod
Normal file
7
example/music/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module music
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/music/main.go
Normal file
11
example/music/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
15
example/music/plg.json
Normal file
15
example/music/plg.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "music",
|
||||
"name_zh": "音乐搜索",
|
||||
"name_en": "Music Search",
|
||||
"version": "0.1.0",
|
||||
"description": "音乐搜索插件,支持搜索歌曲和查看歌词(基于网易云音乐)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["music", "song", "lyrics", "网易云"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
327
example/music/plugin.go
Normal file
327
example/music/plugin.go
Normal file
@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
cli *http.Client
|
||||
}
|
||||
|
||||
type searchResp struct {
|
||||
Result *struct {
|
||||
Songs []songItem `json:"songs"`
|
||||
SongCount int `json:"songCount"`
|
||||
} `json:"result"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type songItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []artist `json:"artists"`
|
||||
Album albumInfo `json:"album"`
|
||||
Duration int `json:"duration"`
|
||||
Mvid int `json:"mvid"`
|
||||
Fee int `json:"fee"`
|
||||
}
|
||||
|
||||
type artist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type albumInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type lyricResp struct {
|
||||
Lrc *lyricData `json:"lrc"`
|
||||
TLrc *lyricData `json:"tlyric"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type lyricData struct {
|
||||
Lyric string `json:"lyric"`
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.cli = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
s.RegisterTool(p.name+"_search", sdk.ToolDef{
|
||||
Name: p.name + "_search",
|
||||
Description: "搜索歌曲,通过关键词查找音乐,返回歌曲列表",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"keyword": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "搜索关键词,如歌曲名、歌手名",
|
||||
},
|
||||
"limit": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "返回结果数量(1-50),默认10",
|
||||
},
|
||||
},
|
||||
"required": []string{"keyword"},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Content string }
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
|
||||
return r.Content
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleSearch)
|
||||
|
||||
s.RegisterTool(p.name+"_lyrics", sdk.ToolDef{
|
||||
Name: p.name + "_lyrics",
|
||||
Description: "获取歌曲歌词,通过歌曲ID查看歌词内容",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"song_id": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "歌曲ID(从搜索结果的 id 字段获取)",
|
||||
},
|
||||
},
|
||||
"required": []string{"song_id"},
|
||||
},
|
||||
}, p.handleLyrics)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
func (p *Plugin) neRequest(path string, params map[string]string) ([]byte, error) {
|
||||
base := "https://music.163.com/api" + path
|
||||
reqURL := base + "?" + urlValues(params).Encode()
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", "https://music.163.com/")
|
||||
resp, err := p.cli.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func urlValues(m map[string]string) url.Values {
|
||||
v := url.Values{}
|
||||
for k, val := range m {
|
||||
v.Set(k, val)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||
keyword, _ := args["keyword"].(string)
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return map[string]interface{}{
|
||||
"content": "请输入搜索关键词",
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
}
|
||||
|
||||
body, err := p.neRequest("/search/get", map[string]string{
|
||||
"s": keyword,
|
||||
"type": "1",
|
||||
"limit": fmt.Sprint(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("搜索失败:%v", err),
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var resp searchResp
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("解析响应失败:%v", err),
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if resp.Code != 200 || resp.Result == nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("搜索失败,响应码:%d", resp.Code),
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
songs := resp.Result.Songs
|
||||
if len(songs) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("未找到与「%s」相关的歌曲", keyword),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("找到 %d 首与「%s」相关的歌曲:\n", resp.Result.SongCount, keyword))
|
||||
for i, s := range songs {
|
||||
var artists []string
|
||||
for _, a := range s.Artists {
|
||||
artists = append(artists, a.Name)
|
||||
}
|
||||
dur := time.Duration(s.Duration) * time.Millisecond
|
||||
minutes := int(dur.Minutes())
|
||||
seconds := int(dur.Seconds()) % 60
|
||||
lines = append(lines, fmt.Sprintf("%d. %s - %s [%02d:%02d] (ID: %d)",
|
||||
i+1, s.Name, strings.Join(artists, "/"), minutes, seconds, s.ID))
|
||||
}
|
||||
|
||||
type songResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
var results []songResult
|
||||
for _, s := range songs {
|
||||
var artists []string
|
||||
for _, a := range s.Artists {
|
||||
artists = append(artists, a.Name)
|
||||
}
|
||||
results = append(results, songResult{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Artists: artists,
|
||||
Album: s.Album.Name,
|
||||
Duration: s.Duration,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.Join(lines, "\n"),
|
||||
"songs": results,
|
||||
"total": resp.Result.SongCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleLyrics(args map[string]interface{}) (interface{}, error) {
|
||||
songID, ok := args["song_id"].(float64)
|
||||
if !ok {
|
||||
return map[string]interface{}{
|
||||
"content": "请提供有效的歌曲ID",
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
id := int64(songID)
|
||||
body, err := p.neRequest("/song/lyric", map[string]string{
|
||||
"id": fmt.Sprint(id),
|
||||
"lv": "-1",
|
||||
"kv": "-1",
|
||||
"tv": "-1",
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("获取歌词失败:%v", err),
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var resp lyricResp
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("解析歌词失败:%v", err),
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if resp.Code != 200 {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("获取歌词失败,响应码:%d", resp.Code),
|
||||
"isError": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
lyric := ""
|
||||
if resp.Lrc != nil {
|
||||
lyric = resp.Lrc.Lyric
|
||||
}
|
||||
|
||||
if lyric == "" {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("歌曲 %d 暂无歌词", id),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clean up lyrics metadata lines and limit length
|
||||
lyric = cleanLyrics(lyric)
|
||||
if len(lyric) > 3000 {
|
||||
lyric = lyric[:3000] + "\n...(歌词过长已截断)"
|
||||
}
|
||||
|
||||
tLyric := ""
|
||||
if resp.TLrc != nil && resp.TLrc.Lyric != "" {
|
||||
tLyric = cleanLyrics(resp.TLrc.Lyric)
|
||||
if len(tLyric) > 1000 {
|
||||
tLyric = tLyric[:1000] + "\n...(翻译过长已截断)"
|
||||
}
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("歌词:\n%s", lyric)
|
||||
if tLyric != "" {
|
||||
result += fmt.Sprintf("\n翻译:\n%s", tLyric)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": result,
|
||||
"lyric": lyric,
|
||||
"tlyric": tLyric,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cleanLyrics(l string) string {
|
||||
lines := strings.Split(l, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
// Skip metadata lines like [ti:...], [ar:...], [al:...], [by:...], [offset:...]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
return strings.Join(cleaned, "\n")
|
||||
}
|
||||
@ -2,6 +2,6 @@ module ocr
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
11
example/ocr/main.go
Normal file
11
example/ocr/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["ocr", "image", "text"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@ -38,6 +39,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"language": map[string]interface{}{"type": "string", "description": "识别语言,默认 chi_sim+eng(中文简体+英文),可选 chi_sim / eng / chi_sim+eng"},
|
||||
},
|
||||
},
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Text string }
|
||||
if json.Unmarshal([]byte(output), &r) == nil && r.Text != "" {
|
||||
return r.Text
|
||||
}
|
||||
var r2 struct{ Content string }
|
||||
if json.Unmarshal([]byte(output), &r2) == nil && r2.Content != "" {
|
||||
return r2.Content
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleOcrImage)
|
||||
|
||||
log.Printf("[%s] plugin started", p.name)
|
||||
@ -128,7 +140,6 @@ func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,6 @@ module qq
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
11
example/qq/main.go
Normal file
11
example/qq/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["qq", "messaging"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": false,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
1241
example/qq/plugin.go
1241
example/qq/plugin.go
File diff suppressed because it is too large
Load Diff
@ -1,101 +0,0 @@
|
||||
/* 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
|
||||
13
example/rss/README.md
Normal file
13
example/rss/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# rss
|
||||
|
||||
rss plugin
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
17
example/rss/go.mod
Normal file
17
example/rss/go.mod
Normal file
@ -0,0 +1,17 @@
|
||||
module rss
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
github.com/mmcdole/gofeed v1.4.0
|
||||
github.com/mmcdole/goxpp/v2 v2.0.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
8
example/rss/go.sum
Normal file
8
example/rss/go.sum
Normal file
@ -0,0 +1,8 @@
|
||||
github.com/mmcdole/gofeed v1.4.0 h1:+efDmI/yJXJgTfa8we5zg9GAKsU+2d7tnpt9QZwvjLQ=
|
||||
github.com/mmcdole/gofeed v1.4.0/go.mod h1:ngV5MTB7UJko6fH3/fG5AkB/ABUGK1ZTePF9iRhzu/c=
|
||||
github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ=
|
||||
github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
11
example/rss/main.go
Normal file
11
example/rss/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
15
example/rss/plg.json
Normal file
15
example/rss/plg.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "rss",
|
||||
"name_zh": "RSS订阅",
|
||||
"name_en": "RSS",
|
||||
"version": "1.0.0",
|
||||
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["rss", "feed", "subscription", "monitor"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
488
example/rss/plugin.go
Normal file
488
example/rss/plugin.go
Normal file
@ -0,0 +1,488 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
const injectDedupWindow = 5 * time.Minute
|
||||
|
||||
type FeedSub struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
AddedAt string `json:"added_at"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
client *http.Client
|
||||
fp *gofeed.Parser
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
feeds []FeedSub
|
||||
seenGUIDs map[string]bool
|
||||
injected map[string]time.Time
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
pollTicker *time.Ticker
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return fallback
|
||||
}
|
||||
switch any(fallback).(type) {
|
||||
case string:
|
||||
if sv, ok := v.(string); ok {
|
||||
return any(sv).(T)
|
||||
}
|
||||
case int64:
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return any(int64(val)).(T)
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
|
||||
return any(n).(T)
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return any(val).(T)
|
||||
case string:
|
||||
if n, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
return any(n).(T)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func readArg(args map[string]interface{}, key string) string {
|
||||
if v, ok := args[key]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readArgInt(args map[string]interface{}, key string, fallback int) int {
|
||||
if v, ok := args[key]; ok && v != nil {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.client = &http.Client{Timeout: 30 * time.Second}
|
||||
p.fp = gofeed.NewParser()
|
||||
p.stopCh = make(chan struct{})
|
||||
p.seenGUIDs = make(map[string]bool)
|
||||
p.injected = make(map[string]time.Time)
|
||||
p.feeds = []FeedSub{}
|
||||
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
}
|
||||
p.loadData()
|
||||
|
||||
// 卸载(删除)时清理订阅数据目录;重载不触发
|
||||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "poll_interval", Default: "30", Type: "string",
|
||||
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
|
||||
Category: "rss",
|
||||
})
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"subscribe", sdk.ToolDef{
|
||||
Name: tp + "subscribe", Description: "Subscribe to an RSS/Atom feed URL",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "Feed URL"},
|
||||
"interval": map[string]interface{}{"type": "integer", "description": "Poll interval in minutes (default: 30, minimum: 5)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleSubscribe)
|
||||
|
||||
s.RegisterTool(tp+"unsubscribe", sdk.ToolDef{
|
||||
Name: tp + "unsubscribe", Description: "Unsubscribe from a feed",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "Feed URL to unsubscribe"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleUnsubscribe)
|
||||
|
||||
s.RegisterTool(tp+"list", sdk.ToolDef{
|
||||
Name: tp + "list", Description: "List all subscribed feeds",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
|
||||
s.RegisterTool(tp+"check_now", sdk.ToolDef{
|
||||
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleCheckNow)
|
||||
|
||||
pollMin := int(getSetting(s.Settings(), "poll_interval", int64(30)))
|
||||
if pollMin < 5 {
|
||||
pollMin = 5
|
||||
}
|
||||
p.pollTicker = time.NewTicker(time.Duration(pollMin) * time.Minute)
|
||||
|
||||
p.wg.Add(1)
|
||||
go p.pollLoop()
|
||||
|
||||
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopOnce.Do(func() { close(p.stopCh) })
|
||||
p.pollTicker.Stop()
|
||||
p.wg.Wait()
|
||||
p.saveData()
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) pollLoop() {
|
||||
defer p.wg.Done()
|
||||
|
||||
p.checkAllFeeds()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.pollTicker.C:
|
||||
p.checkAllFeeds()
|
||||
case <-p.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) checkAllFeeds() {
|
||||
p.mu.RLock()
|
||||
feeds := make([]FeedSub, len(p.feeds))
|
||||
copy(feeds, p.feeds)
|
||||
p.mu.RUnlock()
|
||||
|
||||
for _, feed := range feeds {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
p.checkFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) checkFeed(sub FeedSub) {
|
||||
parsed, err := p.fp.ParseURL(sub.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
title := parsed.Title
|
||||
if title == "" {
|
||||
title = sub.URL
|
||||
}
|
||||
|
||||
var newArticles []*gofeed.Item
|
||||
for _, item := range parsed.Items {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
guid = sub.URL + "|" + guid
|
||||
p.mu.RLock()
|
||||
seen := p.seenGUIDs[guid]
|
||||
p.mu.RUnlock()
|
||||
if !seen {
|
||||
newArticles = append(newArticles, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newArticles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
toInject := make([]*gofeed.Item, 0, len(newArticles))
|
||||
p.mu.Lock()
|
||||
for _, item := range newArticles {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
key := sub.URL + "|" + guid
|
||||
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
|
||||
continue
|
||||
}
|
||||
p.injected[key] = now
|
||||
p.seenGUIDs[key] = true
|
||||
toInject = append(toInject, item)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if len(toInject) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
|
||||
for _, item := range toInject {
|
||||
pubDate := ""
|
||||
if item.PublishedParsed != nil {
|
||||
pubDate = item.PublishedParsed.Format("01-02 15:04")
|
||||
}
|
||||
line := fmt.Sprintf(" • %s", item.Title)
|
||||
if pubDate != "" {
|
||||
line += fmt.Sprintf(" [%s]", pubDate)
|
||||
}
|
||||
if item.Link != "" {
|
||||
line += "\n " + item.Link
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
||||
p.saveData()
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, error) {
|
||||
url := readArg(args, "url")
|
||||
if url == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
for _, f := range p.feeds {
|
||||
if f.URL == url {
|
||||
p.mu.RUnlock()
|
||||
return map[string]interface{}{"isError": true, "content": "Already subscribed to: " + url}, nil
|
||||
}
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
interval := readArgInt(args, "interval", 30)
|
||||
if interval < 5 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
parsed, err := p.fp.ParseURL(url)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Failed to parse feed: " + err.Error()}, nil
|
||||
}
|
||||
|
||||
feedTitle := parsed.Title
|
||||
if feedTitle == "" {
|
||||
feedTitle = url
|
||||
}
|
||||
|
||||
sub := FeedSub{
|
||||
URL: url,
|
||||
Title: feedTitle,
|
||||
AddedAt: time.Now().Format("2006-01-02 15:04"),
|
||||
Interval: interval,
|
||||
}
|
||||
|
||||
guidCount := 0
|
||||
p.mu.Lock()
|
||||
for _, item := range parsed.Items {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
continue
|
||||
}
|
||||
p.seenGUIDs[url+"|"+guid] = true
|
||||
guidCount++
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
p.feeds = append(p.feeds, sub)
|
||||
p.mu.Unlock()
|
||||
p.saveData()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Subscribed to: %s\nTitle: %s\nArticles found: %d\nPoll interval: %d min", url, feedTitle, guidCount, interval),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleUnsubscribe(args map[string]interface{}) (interface{}, error) {
|
||||
url := readArg(args, "url")
|
||||
if url == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i, f := range p.feeds {
|
||||
if f.URL == url {
|
||||
p.feeds = append(p.feeds[:i], p.feeds[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
p.mu.Unlock()
|
||||
return map[string]interface{}{"isError": true, "content": "Not subscribed to: " + url}, nil
|
||||
}
|
||||
|
||||
for guid := range p.seenGUIDs {
|
||||
if strings.HasPrefix(guid, url+"|") {
|
||||
delete(p.seenGUIDs, guid)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.saveData()
|
||||
|
||||
return map[string]interface{}{"content": "Unsubscribed: " + url}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if len(p.feeds) == 0 {
|
||||
return map[string]interface{}{"content": "No subscriptions. Use rss_subscribe to add one."}, nil
|
||||
}
|
||||
|
||||
sort.Slice(p.feeds, func(i, j int) bool {
|
||||
return p.feeds[i].Title < p.feeds[j].Title
|
||||
})
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📡 Subscriptions (%d):", len(p.feeds)))
|
||||
for _, f := range p.feeds {
|
||||
lines = append(lines, fmt.Sprintf(" • %s\n %s (every %dm, added %s)", f.Title, f.URL, f.Interval, f.AddedAt))
|
||||
}
|
||||
|
||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
|
||||
default:
|
||||
}
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
p.checkAllFeeds()
|
||||
}()
|
||||
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) dataFile() string {
|
||||
return filepath.Join(p.dataDir, "feeds.json")
|
||||
}
|
||||
|
||||
func (p *Plugin) loadData() {
|
||||
b, err := os.ReadFile(p.dataFile())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
SeenGUIDs map[string]bool `json:"seen"`
|
||||
}
|
||||
if json.Unmarshal(b, &data) != nil {
|
||||
return
|
||||
}
|
||||
if data.Feeds != nil {
|
||||
p.feeds = data.Feeds
|
||||
}
|
||||
if data.SeenGUIDs != nil {
|
||||
p.seenGUIDs = data.SeenGUIDs
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) saveData() {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
data := struct {
|
||||
Feeds []FeedSub `json:"feeds"`
|
||||
SeenGUIDs map[string]bool `json:"seen"`
|
||||
}{
|
||||
Feeds: p.feeds,
|
||||
SeenGUIDs: p.seenGUIDs,
|
||||
}
|
||||
b, _ := json.MarshalIndent(data, "", " ")
|
||||
os.WriteFile(p.dataFile(), b, 0644)
|
||||
}
|
||||
|
||||
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
||||
func (p *Plugin) cleanupData() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.dataDir == "" {
|
||||
return
|
||||
}
|
||||
for _, f := range []string{"feeds.json"} {
|
||||
path := filepath.Join(p.dataDir, f)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,6 @@ module sanitizer
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
11
example/sanitizer/main.go
Normal file
11
example/sanitizer/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
@ -7,5 +7,9 @@
|
||||
"author": "HomeAgent SDK",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["sanitizer"],
|
||||
"targets": "linux/amd64"
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
|
||||
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
// 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
|
||||
// 同时保留原有"工具调用残留(思维泄漏)"清理。
|
||||
//
|
||||
// 挂载阶段:
|
||||
// - StageOnInput : 清洗用户输入(RawMessage)
|
||||
// - StageAfterToolcall : 清洗工具执行结果(ToolResults),坏字节不进 LLM 上下文
|
||||
// - StagePostAction : 清洗 LLM 输出(LLMText),保留原有思维泄漏清理
|
||||
//
|
||||
// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
|
||||
//
|
||||
// 编译:
|
||||
//
|
||||
@ -13,21 +21,24 @@ import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[::][^}]*\}\}`)
|
||||
atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`)
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
@ -36,10 +47,41 @@ func (p *Plugin) Name() string { return "sanitizer" }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
// 1) 输入清洗
|
||||
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := ctx.RawMessage
|
||||
ctx.RawMessage = cleanText(ctx.RawMessage)
|
||||
if before != ctx.RawMessage {
|
||||
log.Printf("[sanitizer] StageOnInput: cleaned %d bytes", len(before)-len(ctx.RawMessage))
|
||||
}
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 2) 工具结果清洗(坏字节/ANSI 不得进 LLM 上下文)
|
||||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
for i, tr := range ctx.ToolResults {
|
||||
if s, ok := tr.Result.(string); ok {
|
||||
clean := cleanText(s)
|
||||
if clean != s {
|
||||
ctx.ToolResults[i].Result = clean
|
||||
log.Printf("[sanitizer] StageAfterToolcall: tool=%s cleaned %d bytes", tr.Name, len(s)-len(clean))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 3) LLM 输出清洗(保留原有思维泄漏清理 + 新增乱码清洗)
|
||||
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := len(ctx.LLMText)
|
||||
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
|
||||
ctx.LLMText = cleanText(ctx.LLMText)
|
||||
after := len(ctx.LLMText)
|
||||
ctx.Unlock()
|
||||
if before != after {
|
||||
@ -47,16 +89,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
log.Printf("[sanitizer] stage PostAction registered")
|
||||
log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{}, nil
|
||||
}
|
||||
|
||||
// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
func cleanToolCallLeakage(content string) string {
|
||||
if content == "" {
|
||||
return content
|
||||
@ -83,8 +126,12 @@ func cleanToolCallLeakage(content string) string {
|
||||
cleaned = append(cleaned, line)
|
||||
continue
|
||||
}
|
||||
if toolNameRE.MatchString(trimmed) {
|
||||
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") {
|
||||
if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) {
|
||||
continue
|
||||
}
|
||||
if m := toolNameRE.FindStringIndex(trimmed); m != nil {
|
||||
rest := trimmed[m[1]:]
|
||||
if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -100,3 +147,72 @@ func cleanToolCallLeakage(content string) string {
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// cleanText 清洗可能污染 LLM 上下文/输出的文本:
|
||||
// 1. 剥离 ANSI 转义序列(\x1b[...m 等,源自终端输出)
|
||||
// 2. 剔除无效 UTF-8 字节(strings.ToValidUTF8 语义)与已解码的 U+FFFD 替换符,
|
||||
// 避免模型复读坏字节/替换符造成乱码(把坏段落整体丢弃比留残字更干净)
|
||||
func cleanText(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// 先剥离 ANSI 转义:ESC [ 参数 m / ESC ] 标题 / 其他 CSI 序列
|
||||
if strings.ContainsRune(s, 0x1b) {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
if c == 0x1b {
|
||||
// 跳过完整转义序列
|
||||
j := i + 1
|
||||
if j < len(s) {
|
||||
switch s[j] {
|
||||
case '[': // CSI: ESC [ <params> <letter>
|
||||
j++
|
||||
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
case ']': // OSC: ESC ] ... BEL / ST
|
||||
i = j + 1
|
||||
for i < len(s) && s[i] != 0x07 {
|
||||
i++
|
||||
}
|
||||
i++ // skip BEL
|
||||
continue
|
||||
default: // 单字符转义(ESC c ESC 7 等)
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
s = sb.String()
|
||||
}
|
||||
|
||||
// 剔除无效 UTF-8 与 U+FFFD 替换符
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
}
|
||||
if strings.ContainsRune(s, utf8.RuneError) {
|
||||
// 连 U+FFFD 也不留给模型复述
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r != utf8.RuneError {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s = b.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -2,6 +2,31 @@ package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"clean", "你好世界 hello", "你好世界 hello"},
|
||||
{"invalid_utf8", "a\xff\xfe b", "a b"},
|
||||
{"ufffd", "有乱码\ufffd字符", "有乱码字符"},
|
||||
{"multiple_ufffd", "a\ufffd\ufffdb\ufffdc", "abc"},
|
||||
{"ansi_color", "\x1b[31m红色\x1b[0m结束", "红色结束"},
|
||||
{"ansi_cursor", "a\x1b[2K\r\nb", "a\r\nb"},
|
||||
{"ansi_osc", "\x1b]0;title\x07文本", "文本"},
|
||||
{"an_and_ufffd", "\x1b[31m\ufffd中文\x1b[0m", "中文"},
|
||||
{"emoji_kept", "颜文字(・ω・´)和🍎", "颜文字(・ω・´)和🍎"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cleanText(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanToolCallLeakage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
@ -28,4 +53,4 @@ func TestCleanToolCallLeakage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
7
example/vanblog/go.mod
Normal file
7
example/vanblog/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module vanblog-plugin
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/vanblog/plg.json
Normal file
11
example/vanblog/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "vanblog",
|
||||
"name_zh": "VanBlog 博客管理",
|
||||
"name_en": "VanBlog",
|
||||
"version": "1.0.0",
|
||||
"description": "管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["blog", "vanblog", "cms"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
1334
example/vanblog/plugin.go
Normal file
1334
example/vanblog/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
13
example/weather/README.md
Normal file
13
example/weather/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# weather
|
||||
|
||||
weather plugin
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
8
example/weather/go.mod
Normal file
8
example/weather/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module weather
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
|
||||
16
example/weather/plg.json
Normal file
16
example/weather/plg.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "weather",
|
||||
"name_zh": "天气查询",
|
||||
"name_en": "Weather",
|
||||
"version": "1.0.0",
|
||||
"description": "天气查询插件(基于 wttr.in),支持实时天气和未来预报",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["weather", "forecast", "wttr"],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
|
||||
412
example/weather/plugin.go
Normal file
412
example/weather/plugin.go
Normal file
@ -0,0 +1,412 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
client *http.Client
|
||||
defaultLoc string
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
p.client = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "default_location", Default: "", Type: "string",
|
||||
DisplayName: "Default Location", Description: "Default city name for weather queries, e.g. Beijing",
|
||||
Category: "weather",
|
||||
})
|
||||
|
||||
if v, _ := s.Settings().Get("default_location"); v != nil {
|
||||
if vs, ok := v.(string); ok {
|
||||
p.defaultLoc = vs
|
||||
}
|
||||
}
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"current", sdk.ToolDef{
|
||||
Name: tp + "current", Description: "Get current weather for a city",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string", "description": "City name (e.g. Beijing, Shanghai, London). Uses default if omitted."},
|
||||
"units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"},
|
||||
},
|
||||
},
|
||||
// NoMemory: 外部实时数据对记忆计算无长期价值,跳过向量化/关键词提取
|
||||
NoMemory: true,
|
||||
// Cleaner: 工具输出参与记忆计算前先过滤;这里演示用法(保留摘要行)
|
||||
Cleaner: func(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.HasPrefix(line, "🌤") {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return output
|
||||
},
|
||||
}, p.handleCurrent)
|
||||
|
||||
s.RegisterTool(tp+"forecast", sdk.ToolDef{
|
||||
Name: tp + "forecast", Description: "Get weather forecast for next several days",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string", "description": "City name. Uses default if omitted."},
|
||||
"days": map[string]interface{}{"type": "integer", "description": "Number of days (1-7), default 3"},
|
||||
"units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"},
|
||||
},
|
||||
},
|
||||
NoMemory: true,
|
||||
}, p.handleForecast)
|
||||
|
||||
s.RegisterTool(tp+"set_location", sdk.ToolDef{
|
||||
Name: tp + "set_location", Description: "Set default weather location",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string", "description": "City name to set as default"},
|
||||
},
|
||||
"required": []string{"location"},
|
||||
},
|
||||
NoMemory: true,
|
||||
}, p.handleSetLocation)
|
||||
|
||||
// 阶段钩子:own_tools 作用域——仅在本插件的工具被调用时触发
|
||||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
if len(ctx.ToolResults) > 0 {
|
||||
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
|
||||
}
|
||||
return nil
|
||||
}, sdk.StageScopeOwnTools)
|
||||
|
||||
// 输出通道:把天气结果主动推给用户(如 QQ/WebUI 渠道)
|
||||
if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{
|
||||
NoMemory: true,
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
return map[string]interface{}{"content": "weather pushed: " + payload}, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 输入通道:接收天气订阅请求(NoMemory: 通道输入不参与记忆计算)
|
||||
if err := s.RegisterInputChannel(tp+"weather_in", sdk.ChannelDef{NoMemory: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] started\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
type wttrResp struct {
|
||||
CurrentCondition []struct {
|
||||
TempC string `json:"temp_C"`
|
||||
FeelsLikeC string `json:"FeelsLikeC"`
|
||||
Humidity string `json:"humidity"`
|
||||
WindspeedKmph string `json:"windspeedKmph"`
|
||||
Winddir16Point string `json:"winddir16Point"`
|
||||
Pressure string `json:"pressure"`
|
||||
Visibility string `json:"visibility"`
|
||||
WeatherDesc []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"weatherDesc"`
|
||||
LocalObsDateTime string `json:"localObsDateTime"`
|
||||
} `json:"current_condition"`
|
||||
NearestArea []struct {
|
||||
AreaName []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"areaName"`
|
||||
Country []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"country"`
|
||||
Region []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"region"`
|
||||
} `json:"nearest_area"`
|
||||
Weather []wttrDay `json:"weather"`
|
||||
}
|
||||
|
||||
type wttrDay struct {
|
||||
Date string `json:"date"`
|
||||
Astronomy []struct {
|
||||
Sunrise string `json:"sunrise"`
|
||||
Sunset string `json:"sunset"`
|
||||
} `json:"astronomy"`
|
||||
MaxtempC string `json:"maxtempC"`
|
||||
MintempC string `json:"mintempC"`
|
||||
Hourly []struct {
|
||||
TempC string `json:"tempC"`
|
||||
WeatherDesc []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"weatherDesc"`
|
||||
WindspeedKmph string `json:"windspeedKmph"`
|
||||
Winddir16Point string `json:"winddir16Point"`
|
||||
Humidity string `json:"humidity"`
|
||||
FeelsLikeC string `json:"FeelsLikeC"`
|
||||
PrecipMM string `json:"precipMM"`
|
||||
Visibility string `json:"visibility"`
|
||||
} `json:"hourly"`
|
||||
}
|
||||
|
||||
func (p *Plugin) getLoc(args map[string]interface{}) string {
|
||||
if v, ok := args["location"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return p.defaultLoc
|
||||
}
|
||||
|
||||
func (p *Plugin) getUnits(args map[string]interface{}) string {
|
||||
if v, ok := args["units"].(string); ok && (v == "imperial" || v == "metric") {
|
||||
return v
|
||||
}
|
||||
return "metric"
|
||||
}
|
||||
|
||||
func (p *Plugin) fetchWttr(location string) (*wttrResp, error) {
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", strings.ReplaceAll(location, " ", "%20"))
|
||||
resp, err := p.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var data wttrResp
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.CurrentCondition) == 0 {
|
||||
return nil, fmt.Errorf("no weather data for: %s", location)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) displayName(data *wttrResp) string {
|
||||
if len(data.NearestArea) == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
area := data.NearestArea[0]
|
||||
name := ""
|
||||
if len(area.AreaName) > 0 {
|
||||
name = area.AreaName[0].Value
|
||||
}
|
||||
region := ""
|
||||
if len(area.Region) > 0 {
|
||||
region = area.Region[0].Value
|
||||
}
|
||||
country := ""
|
||||
if len(area.Country) > 0 {
|
||||
country = area.Country[0].Value
|
||||
}
|
||||
var parts []string
|
||||
if name != "" {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
if region != "" && region != name {
|
||||
parts = append(parts, region)
|
||||
}
|
||||
if country != "" {
|
||||
parts = append(parts, country)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func convertCtoF(c string) string {
|
||||
if v, err := strconv.ParseFloat(c, 64); err == nil {
|
||||
return fmt.Sprintf("%.0f", v*9/5+32)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCurrent(args map[string]interface{}) (interface{}, error) {
|
||||
location := p.getLoc(args)
|
||||
if location == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "No location specified. Provide a city name or set default_location."}, nil
|
||||
}
|
||||
|
||||
units := p.getUnits(args)
|
||||
|
||||
data, err := p.fetchWttr(location)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Weather request failed: " + err.Error()}, nil
|
||||
}
|
||||
|
||||
cc := data.CurrentCondition[0]
|
||||
place := p.displayName(data)
|
||||
|
||||
desc := ""
|
||||
if len(cc.WeatherDesc) > 0 {
|
||||
desc = cc.WeatherDesc[0].Value
|
||||
}
|
||||
|
||||
unitStr := "°C"
|
||||
windUnit := "km/h"
|
||||
tempStr := cc.TempC
|
||||
feelsStr := cc.FeelsLikeC
|
||||
if units == "imperial" {
|
||||
unitStr = "°F"
|
||||
windUnit = "mph"
|
||||
tempStr = convertCtoF(tempStr)
|
||||
feelsStr = convertCtoF(feelsStr)
|
||||
}
|
||||
|
||||
obsTime := cc.LocalObsDateTime
|
||||
if len(obsTime) > 16 {
|
||||
obsTime = obsTime[:16]
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("🌤 %s — %s\n🌡 %s%s (体感 %s%s)\n💧 湿度 %s%% | 💨 风速 %s %s %s\n🕐 %s",
|
||||
place, desc,
|
||||
tempStr, unitStr, feelsStr, unitStr,
|
||||
cc.Humidity, cc.WindspeedKmph, windUnit, cc.Winddir16Point,
|
||||
obsTime)
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": result,
|
||||
"location": place,
|
||||
"temp": cc.TempC,
|
||||
"feels_like": cc.FeelsLikeC,
|
||||
"humidity": cc.Humidity,
|
||||
"wind_speed": cc.WindspeedKmph,
|
||||
"weather": desc,
|
||||
"observed": obsTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error) {
|
||||
location := p.getLoc(args)
|
||||
if location == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "No location specified."}, nil
|
||||
}
|
||||
|
||||
days := 3
|
||||
if v, ok := args["days"].(float64); ok {
|
||||
d := int(v)
|
||||
if d >= 1 && d <= 7 {
|
||||
days = d
|
||||
}
|
||||
}
|
||||
|
||||
units := p.getUnits(args)
|
||||
|
||||
data, err := p.fetchWttr(location)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "Forecast request failed: " + err.Error()}, nil
|
||||
}
|
||||
|
||||
place := p.displayName(data)
|
||||
|
||||
unitStr := "°C"
|
||||
if units == "imperial" {
|
||||
unitStr = "°F"
|
||||
}
|
||||
|
||||
dayCount := days
|
||||
if dayCount > len(data.Weather) {
|
||||
dayCount = len(data.Weather)
|
||||
}
|
||||
daysData := data.Weather[:dayCount]
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("📅 %d日天气预报 — %s", days, place))
|
||||
for _, day := range daysData {
|
||||
t, err := time.Parse("2006-01-02", day.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
weekday := t.Weekday().String()[:3]
|
||||
|
||||
maxT := day.MaxtempC
|
||||
minT := day.MintempC
|
||||
desc := ""
|
||||
precip := ""
|
||||
|
||||
if len(day.Hourly) > 0 {
|
||||
mid := len(day.Hourly) / 2
|
||||
if len(day.Hourly[mid].WeatherDesc) > 0 {
|
||||
desc = day.Hourly[mid].WeatherDesc[0].Value
|
||||
}
|
||||
totalPrecip := 0.0
|
||||
for _, h := range day.Hourly {
|
||||
if pv, err := strconv.ParseFloat(h.PrecipMM, 64); err == nil {
|
||||
totalPrecip += pv
|
||||
}
|
||||
}
|
||||
if totalPrecip > 0 {
|
||||
precip = fmt.Sprintf(" 🌧%.1fmm", totalPrecip)
|
||||
}
|
||||
}
|
||||
|
||||
if units == "imperial" {
|
||||
maxT = convertCtoF(maxT)
|
||||
minT = convertCtoF(minT)
|
||||
}
|
||||
|
||||
sunrise, sunset := "", ""
|
||||
if len(day.Astronomy) > 0 {
|
||||
sunrise = day.Astronomy[0].Sunrise
|
||||
sunset = day.Astronomy[0].Sunset
|
||||
}
|
||||
|
||||
datePart := ""
|
||||
if len(day.Date) >= 8 {
|
||||
datePart = day.Date[5:7] + "/" + day.Date[8:]
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s — %s~%s%s %s", weekday, datePart, minT, maxT, unitStr, desc)
|
||||
if precip != "" {
|
||||
line += precip
|
||||
}
|
||||
if sunrise != "" && sunset != "" {
|
||||
line += fmt.Sprintf(" 🌅%s 🌇%s", sunrise, sunset)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
cc := data.CurrentCondition[0]
|
||||
nowDesc := ""
|
||||
if len(cc.WeatherDesc) > 0 {
|
||||
nowDesc = cc.WeatherDesc[0].Value
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("\n当前:%s %s°C", nowDesc, cc.TempC))
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.Join(lines, "\n"),
|
||||
"location": place,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSetLocation(args map[string]interface{}) (interface{}, error) {
|
||||
loc, _ := args["location"].(string)
|
||||
if loc == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "Location is required"}, nil
|
||||
}
|
||||
|
||||
p.sdk.Settings().Set("default_location", loc)
|
||||
p.defaultLoc = loc
|
||||
return map[string]interface{}{"content": fmt.Sprintf("Default location set to: %s", loc)}, nil
|
||||
}
|
||||
81
meta/meta.go
81
meta/meta.go
@ -1,11 +1,12 @@
|
||||
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
||||
// 版本号应与核心 meta.Version 保持一致。
|
||||
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
|
||||
package meta
|
||||
|
||||
var (
|
||||
// Version 是 HomeAgent SDK 版本号。
|
||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||
Version = "0.7.1"
|
||||
Version = "0.9.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -15,9 +16,87 @@ var (
|
||||
|
||||
// SDKName 是 SDK 名称。
|
||||
SDKName = "HomeAgent SDK"
|
||||
|
||||
// CoreModule 是核心仓的 Go module path,供 plugindev 生成 go.mod 时使用。
|
||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||
|
||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||
CoreVersion = "0.9.0"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
func FullVersion() string {
|
||||
return SDKName + " v" + Version + " (" + Commit + ")"
|
||||
}
|
||||
|
||||
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
|
||||
// ABI 标识版本直接取内核版本号字符串(semver),与核心 Version 保持一致,不使用独立数字编码。
|
||||
// 协商层(C 结构体 int version 字段)使用 CABINum:由版本字符串派生的整数(major*100 + minor)。
|
||||
// 映射:v0.8.x → CABINum=800;v0.9.x → CABINum=900(invoke_stage 写回)。
|
||||
// 小版本(patch)演进不影响 ABI,CABINum 不变。version_min 保证旧 ABI 插件仍可加载。
|
||||
|
||||
var (
|
||||
// ABIVersion 是 ABI 标识版本(字符串 semver,与 SDK CoreVersion 对齐)。
|
||||
ABIVersion = CoreVersion
|
||||
// ABIVersionMin 是兼容的最低 ABI 标识版本。
|
||||
ABIVersionMin = "0.8.0"
|
||||
)
|
||||
|
||||
const (
|
||||
// CABINum 是 C 层协商用的整数版本(major*100 + minor),随 ABIVersion 派生。
|
||||
CABINum = 900
|
||||
// CABINumMin 是 C 层兼容的最低整数版本。
|
||||
// 旧工具链(v0.8 之前)写入的整数 version=1,无写回能力但与新内核结构兼容,
|
||||
// 因此最小值保持 1 以兼容全部旧插件(新插件 900 匹配,旧插件 1/2 通过);
|
||||
// 仅当未来内核 ABI 破坏兼容时才提高该值。
|
||||
CABINumMin = 1
|
||||
)
|
||||
|
||||
// ---- Dispatch Method IDs(与核心仓 internal/meta/meta.go 同步) ----
|
||||
const (
|
||||
CoreRegisterTool = 1
|
||||
CoreRegisterStage = 2
|
||||
CoreRegisterOutputCh = 3
|
||||
CoreRegisterPluginAPI = 4
|
||||
CoreInjectText = 5
|
||||
CoreInjectInterruptText = 6
|
||||
CoreInjectTextNoMemory = 7
|
||||
CoreSetAutoRestart = 8
|
||||
CoreMemoryRecall = 9
|
||||
CoreMemoryCommit = 10
|
||||
CoreMemoryIntrospect = 11
|
||||
CoreMemoryMerge = 12
|
||||
CoreMemoryPurge = 13
|
||||
CoreDocQuery = 14
|
||||
CoreKnowledgeSearch = 15
|
||||
CoreSettingsGet = 16
|
||||
CoreSettingsSet = 17
|
||||
CoreSettingsRegisterDef = 18
|
||||
CoreLLMListSources = 19
|
||||
CoreLLMSetSource = 20
|
||||
CoreSocialGetPerson = 21
|
||||
CoreSocialGetNetwork = 22
|
||||
CoreSubscribe = 23
|
||||
CoreUnsubscribe = 24
|
||||
CoreFreeString = 25
|
||||
CoreSettingsGetCore = 26
|
||||
CoreSettingsSetCore = 27
|
||||
CoreSettingsListCore = 28
|
||||
CoreSettingsGetPlugin = 29
|
||||
CoreSettingsSetPlugin = 30
|
||||
CoreSettingsListPlugin = 31
|
||||
CoreDocInsert = 32
|
||||
CoreDocRemove = 33
|
||||
CoreDocStats = 34
|
||||
CoreKnowledgeAdd = 35
|
||||
CoreKnowledgeList = 36
|
||||
CoreLLMCurrentSource = 37
|
||||
CoreSocialGetTrait = 38
|
||||
CoreSocialGetRelations = 39
|
||||
CoreSocialListPersons = 40
|
||||
CoreTextMemoryAppend = 41
|
||||
CoreSettingsList = 42
|
||||
CoreSettingsDefs = 43
|
||||
CoreSettingsDump = 44
|
||||
CoreSettingsPlugins = 45
|
||||
)
|
||||
|
||||
63
package/build.sh
Executable file
63
package/build.sh
Executable file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
|
||||
GOCACHE="${GOCACHE:-}"
|
||||
GOPATH="${GOPATH:-}"
|
||||
|
||||
TARGET="${1:-native}"
|
||||
COMPONENT="${2:-all}"
|
||||
|
||||
case "$TARGET" in
|
||||
native) GOOS="" GOARCH="" ;;
|
||||
linux/amd64) GOOS=linux GOARCH=amd64 ;;
|
||||
linux/arm64) GOOS=linux GOARCH=arm64 ;;
|
||||
darwin/amd64) GOOS=darwin GOARCH=amd64 ;;
|
||||
darwin/arm64) GOOS=darwin GOARCH=arm64 ;;
|
||||
windows/amd64) GOOS=windows GOARCH=amd64 ;;
|
||||
all)
|
||||
"$0" linux/amd64 "$COMPONENT"
|
||||
"$0" linux/arm64 "$COMPONENT"
|
||||
"$0" darwin/amd64 "$COMPONENT"
|
||||
"$0" darwin/arm64 "$COMPONENT"
|
||||
"$0" windows/amd64 "$COMPONENT"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target: $TARGET"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|plugindev]"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
if [ -n "${GOOS:-}" ]; then
|
||||
SUFFIX="${GOOS}_${GOARCH}"
|
||||
export GOOS GOARCH
|
||||
fi
|
||||
export CGO_ENABLED=0
|
||||
[ -n "$GOCACHE" ] && export GOCACHE
|
||||
[ -n "$GOPATH" ] && export GOPATH
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
build_plugindev() {
|
||||
local src="tools/plugindev"
|
||||
local out="$BUILD_DIR/plugindev${SUFFIX:+_$SUFFIX}"
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] plugindev ${GOOS:-linux}/${GOARCH:-amd64} → $out"
|
||||
cd "$PROJECT_ROOT/$src"
|
||||
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
|
||||
-o "$out" .
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
cd "$PROJECT_ROOT"
|
||||
}
|
||||
|
||||
case "$COMPONENT" in
|
||||
all|plugindev) build_plugindev ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
esac
|
||||
137
package/toolchain.nsi
Normal file
137
package/toolchain.nsi
Normal file
@ -0,0 +1,137 @@
|
||||
!include "MUI2.nsh"
|
||||
!include "nsDialogs.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
|
||||
!define PRODUCT_NAME "HomeAgent Toolchain"
|
||||
!define PRODUCT_PUBLISHER "HomeAgent Team"
|
||||
!define PRODUCT_VERSION "0.7.1"
|
||||
!define PRODUCT_DISPLAY_NAME "HomeAgent 工具链"
|
||||
!define OUTPUT_FILE "HomeAgent_v${PRODUCT_VERSION}_Toolchain_win64.exe"
|
||||
!define SDK_VERSION "v0.7.1"
|
||||
|
||||
Name "${PRODUCT_DISPLAY_NAME} v${PRODUCT_VERSION}"
|
||||
OutFile "${OUTPUT_FILE}"
|
||||
InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}"
|
||||
InstallDirRegKey HKLM "Software\${PRODUCT_NAME}" ""
|
||||
RequestExecutionLevel admin
|
||||
BrandingText "HomeAgent Toolchain Installer"
|
||||
SetCompressor /SOLID lzma
|
||||
ShowInstDetails show
|
||||
ShowUninstDetails show
|
||||
|
||||
Var hasGit
|
||||
Var sdkInstallOk
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
Page custom pageConfirm pageConfirmLeave
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Function .onInit
|
||||
!insertmacro MUI_LANGDLL_DISPLAY
|
||||
StrCpy $hasGit "0"
|
||||
StrCpy $sdkInstallOk "0"
|
||||
FunctionEnd
|
||||
|
||||
Function pageConfirm
|
||||
!insertmacro MUI_HEADER_TEXT "确认安装" "将安装 HomeAgent 工具链并自动下载 SDK ${SDK_VERSION}"
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
${If} $0 == error
|
||||
Abort
|
||||
${EndIf}
|
||||
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
|
||||
Pop $0
|
||||
${NSD_CreateLabel} 15u 20u 100% 12u "• plugindev.exe — 插件开发工具"
|
||||
Pop $0
|
||||
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
|
||||
Pop $0
|
||||
${NSD_CreateLabel} 0 60u 100% 20u "SDK 需要 Git 客户端。如果未安装 Git,请先安装:$\r$\nhttps://git-scm.com/downloads"
|
||||
Pop $0
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function pageConfirmLeave
|
||||
FunctionEnd
|
||||
|
||||
Section "Install" SEC_INSTALL
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
DetailPrint "复制工具链文件..."
|
||||
File "plugindev.exe"
|
||||
|
||||
DetailPrint "创建快捷方式..."
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk" "$INSTDIR\plugindev.exe" "" "$INSTDIR\plugindev.exe" 0
|
||||
|
||||
DetailPrint "配置环境变量..."
|
||||
; Add to system PATH
|
||||
ReadRegStr $0 HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PATH"
|
||||
${If} $0 != ""
|
||||
${If} $0 != "*$INSTDIR*"
|
||||
StrCpy $0 "$0;$INSTDIR"
|
||||
WriteRegStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PATH" $0
|
||||
${EndIf}
|
||||
${Else}
|
||||
WriteRegStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PATH" "$INSTDIR"
|
||||
${EndIf}
|
||||
WriteRegStr HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR" "$INSTDIR\sdk"
|
||||
WriteRegStr HKLM "Software\${PRODUCT_NAME}" "" "$INSTDIR"
|
||||
|
||||
DetailPrint "检测 Git 客户端..."
|
||||
nsExec::ExecToStack '"git" --version'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 == 0
|
||||
StrCpy $hasGit "1"
|
||||
DetailPrint "Git 已安装: $1"
|
||||
${Else}
|
||||
DetailPrint "未检测到 Git,将跳过 SDK 自动下载"
|
||||
DetailPrint "安装完成后请手动运行: plugindev sdk install ${SDK_VERSION}"
|
||||
${EndIf}
|
||||
|
||||
${If} $hasGit == "1"
|
||||
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
|
||||
nsExec::ExecToStack '"$INSTDIR\plugindev.exe" sdk install ${SDK_VERSION}'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 == 0
|
||||
StrCpy $sdkInstallOk "1"
|
||||
DetailPrint "SDK ${SDK_VERSION} 下载完成"
|
||||
DetailPrint "正在激活 SDK ${SDK_VERSION}..."
|
||||
nsExec::Exec '"$INSTDIR\plugindev.exe" sdk use ${SDK_VERSION}'
|
||||
Pop $0
|
||||
${Else}
|
||||
DetailPrint "SDK 下载失败 (错误码: $0)"
|
||||
DetailPrint "请手动运行: plugindev sdk install ${SDK_VERSION}"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_DISPLAY_NAME} v${PRODUCT_VERSION}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" "$INSTDIR\Uninstall.exe"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Publisher" "${PRODUCT_PUBLISHER}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayVersion" "${PRODUCT_VERSION}"
|
||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoModify" 1
|
||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoRepair" 1
|
||||
|
||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
Delete "$INSTDIR\Uninstall.exe"
|
||||
Delete "$INSTDIR\plugindev.exe"
|
||||
RMDir /r "$INSTDIR\sdk"
|
||||
RMDir "$INSTDIR"
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk"
|
||||
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
|
||||
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
||||
DeleteRegKey HKLM "Software\${PRODUCT_NAME}"
|
||||
SectionEnd
|
||||
103
sdk/plugin.go
103
sdk/plugin.go
@ -35,6 +35,14 @@ const (
|
||||
StageAfterOutput Stage = "after_output"
|
||||
)
|
||||
|
||||
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||
type ChannelDef struct {
|
||||
NoMemory bool
|
||||
Cleaner func(string) string
|
||||
}
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
type StageContext struct {
|
||||
mu sync.RWMutex
|
||||
@ -92,6 +100,8 @@ type ToolDef struct {
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||
}
|
||||
|
||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||
@ -101,6 +111,9 @@ type IOInjector interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||
InjectInputSync(source, channel, text string) string
|
||||
}
|
||||
|
||||
// EventType identifies the kind of system event.
|
||||
@ -154,8 +167,11 @@ type StageRegistrar func(stage Stage, handler StageHandler)
|
||||
// APIRegistrar registers a plugin API for external access.
|
||||
type APIRegistrar func(name string) error
|
||||
|
||||
// InputChannelRegistrar registers an input channel with its memory behavior.
|
||||
type InputChannelRegistrar func(name string, def ChannelDef) error
|
||||
|
||||
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||
type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error
|
||||
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||
|
||||
// Output capability flags
|
||||
const (
|
||||
@ -174,6 +190,7 @@ type PluginSDK struct {
|
||||
regStage StageRegistrar
|
||||
regAPI APIRegistrar
|
||||
regOutput OutputChannelRegistrar
|
||||
regInput InputChannelRegistrar
|
||||
io IOInjector
|
||||
mem MemoryAPI
|
||||
textMem TextMemoryAPI
|
||||
@ -185,6 +202,12 @@ type PluginSDK struct {
|
||||
events EventSubscriber
|
||||
|
||||
autoRestart bool
|
||||
|
||||
stopMu sync.Mutex
|
||||
stopHandlers []func()
|
||||
|
||||
removeMu sync.Mutex
|
||||
removeHandlers []func()
|
||||
}
|
||||
|
||||
// New creates a PluginSDK with the given dependencies.
|
||||
@ -287,10 +310,21 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||
// name: channel name (e.g. "qq", "webui")
|
||||
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||
// desc: description of the channel, expected meta format, and type enum
|
||||
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error {
|
||||
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
|
||||
if s.regOutput != nil {
|
||||
return s.regOutput(name, caps, desc, handler)
|
||||
return s.regOutput(name, caps, desc, def, handler)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterInputChannel registers an input channel with its memory behavior.
|
||||
// def.NoMemory: 此通道输入不参与记忆计算
|
||||
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
|
||||
if s.regInput != nil {
|
||||
return s.regInput(name, def)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -298,6 +332,9 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, ha
|
||||
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
|
||||
|
||||
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r }
|
||||
|
||||
// SetIOInjector sets the IO injector (called by the core at startup).
|
||||
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
|
||||
|
||||
@ -333,9 +370,69 @@ func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||
// returning the reply text (empty string if none). Replies must be dispatched back
|
||||
// to the source channel by the caller.
|
||||
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
||||
if s.io == nil {
|
||||
return ""
|
||||
}
|
||||
return s.io.InjectInputSync(source, channel, text)
|
||||
}
|
||||
|
||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
|
||||
|
||||
// AutoRestart 返回插件是否允许自动重启。
|
||||
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
|
||||
|
||||
// RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||
// 适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
|
||||
// 可注册多个;执行后清空(进程停止前只执行一次)。
|
||||
func (s *PluginSDK) RegisterStopHandler(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.stopMu.Lock()
|
||||
s.stopHandlers = append(s.stopHandlers, fn)
|
||||
s.stopMu.Unlock()
|
||||
}
|
||||
|
||||
// RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
|
||||
// 由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
|
||||
func (s *PluginSDK) RunStopHandlers() {
|
||||
s.stopMu.Lock()
|
||||
handlers := append([]func(){}, s.stopHandlers...)
|
||||
s.stopHandlers = nil
|
||||
s.stopMu.Unlock()
|
||||
for i := len(handlers) - 1; i >= 0; i-- {
|
||||
handlers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
|
||||
// 注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
|
||||
// 适用于清理外部资源、删除配置表、下线状态等删除后处理。
|
||||
// 可注册多个;执行后清空(一次删除只执行一次)。
|
||||
func (s *PluginSDK) RegisterOnRemoveHandler(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.removeMu.Lock()
|
||||
s.removeHandlers = append(s.removeHandlers, fn)
|
||||
s.removeMu.Unlock()
|
||||
}
|
||||
|
||||
// RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
|
||||
// 由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
|
||||
func (s *PluginSDK) RunOnRemoveHandlers() {
|
||||
s.removeMu.Lock()
|
||||
handlers := append([]func(){}, s.removeHandlers...)
|
||||
s.removeHandlers = nil
|
||||
s.removeMu.Unlock()
|
||||
for i := len(handlers) - 1; i >= 0; i-- {
|
||||
handlers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,3 +177,77 @@ func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
|
||||
s := &PluginSDK{name: "test"}
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||
}
|
||||
|
||||
func TestToolDefCleaner(t *testing.T) {
|
||||
called := false
|
||||
def := ToolDef{
|
||||
Name: "test_clean",
|
||||
Description: "A test tool with cleaner",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
Cleaner: func(output string) string {
|
||||
called = true
|
||||
return "cleaned:" + output
|
||||
},
|
||||
}
|
||||
if def.Cleaner == nil {
|
||||
t.Fatal("Cleaner should not be nil")
|
||||
}
|
||||
result := def.Cleaner("raw output")
|
||||
if !called {
|
||||
t.Error("Cleaner was not called")
|
||||
}
|
||||
if result != "cleaned:raw output" {
|
||||
t.Errorf("expected 'cleaned:raw output', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolDefNoMemory(t *testing.T) {
|
||||
def := ToolDef{
|
||||
Name: "test_nomem",
|
||||
Description: "A test tool with NoMemory",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
NoMemory: true,
|
||||
}
|
||||
if !def.NoMemory {
|
||||
t.Error("NoMemory should be true")
|
||||
}
|
||||
if def.Cleaner != nil {
|
||||
t.Error("Cleaner should be nil when not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolDefNoMemoryDefaultFalse(t *testing.T) {
|
||||
def := ToolDef{
|
||||
Name: "test_default",
|
||||
Description: "A test tool with defaults",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}
|
||||
if def.NoMemory {
|
||||
t.Error("NoMemory should default to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolDefRegisterPreservesNoMemory(t *testing.T) {
|
||||
var capturedDef ToolDef
|
||||
regTool := func(name string, def ToolDef, handler ToolHandler) error {
|
||||
capturedDef = def
|
||||
return nil
|
||||
}
|
||||
s := &PluginSDK{regTool: regTool, name: "test"}
|
||||
def := ToolDef{
|
||||
Name: "test_tool",
|
||||
Description: "test desc",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
NoMemory: true,
|
||||
Cleaner: func(s string) string { return s },
|
||||
}
|
||||
s.RegisterTool("test_tool", def, func(args map[string]interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
})
|
||||
if !capturedDef.NoMemory {
|
||||
t.Error("NoMemory should be preserved through RegisterTool")
|
||||
}
|
||||
if capturedDef.Cleaner == nil {
|
||||
t.Error("Cleaner should be preserved through RegisterTool")
|
||||
}
|
||||
}
|
||||
|
||||
6
tools/gengskill/.gitignore
vendored
Normal file
6
tools/gengskill/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
report/
|
||||
figures/
|
||||
*.egg-info/
|
||||
21
tools/gengskill/LICENSE
Normal file
21
tools/gengskill/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Geng Skill Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
319
tools/gengskill/README.md
Normal file
319
tools/gengskill/README.md
Normal file
@ -0,0 +1,319 @@
|
||||
<p align="center">
|
||||
<img src="assets/banner.png" alt="Geng Skill Banner" width="100%">
|
||||
</p>
|
||||
|
||||
<h1 align="center">🔬 Geng Skill — 学术数据打假检测工具</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>用数据说话,让造假无所遁形 · Inspired by "耿同学讲故事"</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.8%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.8+">
|
||||
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=for-the-badge" alt="MIT License">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.0-7c3aed?style=for-the-badge" alt="Version 2.0.0">
|
||||
<img src="https://img.shields.io/badge/Tests-24%2F24_Passed-22c55e?style=for-the-badge" alt="Tests Passing">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-快速开始">Quick Start</a> •
|
||||
<a href="#-工作原理">How It Works</a> •
|
||||
<a href="#-三种输入模式">Input Modes</a> •
|
||||
<a href="#-检测模块">Detection Modules</a> •
|
||||
<a href="#-实战案例">Example</a> •
|
||||
<a href="#-文档">Documentation</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 这是什么?
|
||||
|
||||
**Geng Skill** 是一套基于统计学原理的学术论文数据造假检测工具包。
|
||||
|
||||
2026 年 4 月起,科普博主"耿同学讲故事"凭一台电脑和几个统计方法,连续揪出多所 985 高校顶尖学者的论文造假——同济大学 Nature 论文院长免职、南开大学 Nature 子刊正在调查……他证明了一件事:**造假的数据一定会留下统计学破绽。**
|
||||
|
||||
本项目将"耿同学"的技术方法论系统化、工具化,让任何人都能一键检测论文数据是否存在造假嫌疑。
|
||||
|
||||
> 💡 **核心原理**:真实实验数据具有随机性;人为编造的数据会呈现不自然的数学规律。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/YOUR_USERNAME/geng-skill.git
|
||||
cd geng-skill
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 一键自动扫描(Scale 模式)
|
||||
python3 scripts/input_pipeline.py --input your_data.csv --mode scale
|
||||
```
|
||||
|
||||
就这么简单。Scale 模式会**自动扫描所有数值列**,运行 6 种检测算法,并按嫌疑程度从高到低排列结果。
|
||||
|
||||
---
|
||||
|
||||
## 🧠 工作原理
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/architecture.png" alt="System Architecture" width="90%">
|
||||
</p>
|
||||
|
||||
系统分为三层:
|
||||
|
||||
### 第一层 · 数据输入 Input Pipeline
|
||||
|
||||
支持 **PDF 论文**、**Excel 原始数据**、**CSV 表格** 三种格式。自动提取表格、识别数值列、标准化数据格式。
|
||||
|
||||
### 第二层 · 检测引擎 Detection Engine
|
||||
|
||||
6 个独立的统计检测模块,从不同角度分析数据异常:
|
||||
|
||||
| 模块 | 检测目标 | 方法 |
|
||||
|------|----------|------|
|
||||
| **末位数字检测** Last Digit | 末位数字集中度异常 | Chi-squared vs 均匀分布 |
|
||||
| **本福特定律** Benford's Law | 首位数字分布偏离 | Chi-squared + MAD |
|
||||
| **GRIM 测试** | 均值与样本量不兼容 | 离散粒度校验 |
|
||||
| **固定关系检测** Fixed Ratio ⭐ | 实验组间存在完美数学关系 | 比值/回归分析 |
|
||||
| **小数位一致性** Decimal | 小数部分模式重复 | 自相关 + 熵分析 |
|
||||
| **图像重复检测** Image Dup | 同一图片在不同条件下重复使用 | 感知哈希 + SSIM |
|
||||
|
||||
### 第三层 · 输出报告 Output
|
||||
|
||||
生成出版级可视化图表、综合风险评分(0–100)、以及详细的 HTML/Markdown 检测报告,精确标注每个可疑数据点。
|
||||
|
||||
---
|
||||
|
||||
## 📥 三种输入模式
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/workflow.png" alt="Workflow" width="90%">
|
||||
</p>
|
||||
|
||||
### 模式一:论文 PDF 输入
|
||||
|
||||
直接从 PDF 论文中提取数据表格:
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input paper.pdf --mode extract
|
||||
```
|
||||
|
||||
### 模式二:Excel / CSV 原始数据
|
||||
|
||||
处理从论文下载的 Supplementary Data:
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input supplementary_data.xlsx --mode extract
|
||||
python3 scripts/input_pipeline.py --input table_s1.csv --mode extract
|
||||
```
|
||||
|
||||
### 模式三:Scale 自动扫描 ⭐(推荐)
|
||||
|
||||
**让工具自己去找问题。** 无需指定检测哪些列、用哪些方法——全部自动:
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input data.csv --mode scale
|
||||
```
|
||||
|
||||
Scale 模式会:
|
||||
1. 自动识别所有数值列
|
||||
2. 运行 6 大检测模块
|
||||
3. 交叉验证各模块结果
|
||||
4. 输出 **suspicion_ranking**(嫌疑排名榜)
|
||||
|
||||
---
|
||||
|
||||
## 🔬 检测模块
|
||||
|
||||
### ⭐ 固定关系检测(核心 · 耿同学的看家本领)
|
||||
|
||||
这是最致命的检测手段。如果两组"独立实验"数据之间存在**完美的固定数学关系**(比如每个样本 Treatment = Control × 2.0),那几乎可以断定是造假。
|
||||
|
||||
```python
|
||||
from fixed_relation_test import fixed_relation_test
|
||||
|
||||
result = fixed_relation_test(control_data, treatment_data)
|
||||
# result['risk_score'] = 95 → 检测到精确 2.0 倍关系!
|
||||
```
|
||||
|
||||
**为什么这能定性?** 即使药物真的让蛋白表达提高 2 倍,每个样本也会有生物学个体差异。20 个样本**全部**精确到小数点后多位都是 2.000 倍——概率趋近于零。
|
||||
|
||||
### 本福特定律检测 Benford's Law
|
||||
|
||||
跨越多个数量级的自然数据,首位数字遵循特定概率分布(1 最多,9 最少)。人为编造的数据会偏离:
|
||||
|
||||
```python
|
||||
from benford_test import benford_test
|
||||
result = benford_test(values)
|
||||
```
|
||||
|
||||
### GRIM 测试
|
||||
|
||||
对于整数取值的数据(如李克特量表 1–5 分),给定样本量 n,并非所有均值都是数学上可能的:
|
||||
|
||||
```python
|
||||
from grim_test import grim_test_single
|
||||
result = grim_test_single(mean='3.47', n=25, decimals=2)
|
||||
# result['consistent'] = False → 这个均值是不可能存在的!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 实战案例
|
||||
|
||||
### 输入:一篇可疑的生物医学论文数据
|
||||
|
||||
论文声称对小鼠进行了三组独立实验(Control / Treatment A / Treatment B),数据如下:
|
||||
|
||||
```csv
|
||||
sample_id,control,treatment_a,treatment_b
|
||||
1,2.34,4.68,7.02
|
||||
2,3.12,6.24,9.36
|
||||
3,1.87,3.74,5.61
|
||||
4,4.56,9.12,13.68
|
||||
5,2.98,5.96,8.94
|
||||
...
|
||||
```
|
||||
|
||||
### 运行 Scale 模式自动检测
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input data.csv --mode scale
|
||||
```
|
||||
|
||||
### 输出:精准定位问题
|
||||
|
||||
```
|
||||
=== Scale Mode: 自动检测结果 ===
|
||||
|
||||
🔴 [1.00] control ↔ treatment_a ← 精确固定比值 = 2.000
|
||||
🔴 [1.00] control ↔ treatment_b ← 精确固定比值 = 3.000
|
||||
🔴 [1.00] treatment_a ↔ treatment_b ← 精确固定比值 = 1.500
|
||||
🟠 [0.99] treatment_a ← 末位数字分布异常
|
||||
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ 综合风险评分: 92/100 🔴 极高风险 ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ 三组"独立实验"数据之间存在完美的整数倍关系。 ║
|
||||
║ 在真实生物实验中,这种情况出现的概率约等于零。 ║
|
||||
║ 数据极大概率为人工编造。 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 风险评分体系
|
||||
|
||||
| 分数 | 等级 | 含义 | 建议行动 |
|
||||
|------|------|------|----------|
|
||||
| 0–25 | 🟢 低风险 | 未发现异常 | 无需干预 |
|
||||
| 26–50 | 🟡 中等 | 存在轻微模式,可能是正常波动 | 建议复核 |
|
||||
| 51–75 | 🟠 高风险 | 多项指标异常 | 深入调查 |
|
||||
| 76–100 | 🔴 极高风险 | 系统性异常 | 正式举报 |
|
||||
|
||||
**置信度规则:**
|
||||
- 单一模块报警 → 标注为"待确认线索"
|
||||
- 2 个以上独立模块同时报警 → 标注为"高度可疑"
|
||||
- 仅当综合分 > 75 **且**多模块交叉确认时,才标注"极高风险"
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ 准确性与免责声明
|
||||
|
||||
### ✅ 本工具能做什么
|
||||
|
||||
- 检测不同数据列之间的固定数学关系
|
||||
- 识别数字分布的统计学异常
|
||||
- 标记数学上不可能的统计报告值
|
||||
- 发现重复使用/篡改的论文图片
|
||||
- 提供量化的风险评估和置信度等级
|
||||
|
||||
### ❌ 本工具不能做什么
|
||||
|
||||
- 不能证明造假的主观意图
|
||||
- 不能检测加了随机噪声的"高明造假"
|
||||
- 不能替代领域专家的判断
|
||||
- 不具备法律效力
|
||||
|
||||
### ⚠️ 重要声明
|
||||
|
||||
> **本工具仅提供统计学层面的异常筛查功能。**
|
||||
> 输出结果为"疑点线索"(Suspicious Indicators),而非"造假判定"(Fraud Determination)。
|
||||
>
|
||||
> - 统计异常可能有合理的科学解释(仪器精度限制、数据标准化处理、单位转换等)
|
||||
> - 最终判定需要领域专家复核和正式调查程序
|
||||
> - 通过全部检测 ≠ 数据一定真实(某些造假无法被统计方法捕获)
|
||||
> - 使用者需自行承担因不当使用(如公开发布未经验证的指控)造成的一切后果
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
geng-skill/
|
||||
├── scripts/ 核心引擎
|
||||
│ ├── input_pipeline.py 统一输入(PDF/Excel/CSV + Scale 模式)
|
||||
│ ├── visualization.py 出版级可视化图表
|
||||
│ ├── report_generator.py HTML + Markdown 报告生成
|
||||
│ ├── geng_assess.py 综合评估引擎
|
||||
│ ├── last_digit_test.py 末位数字检测
|
||||
│ ├── benford_test.py 本福特定律检测
|
||||
│ ├── grim_test.py GRIM 均值一致性测试
|
||||
│ ├── fixed_relation_test.py 固定关系检测 ⭐
|
||||
│ ├── decimal_consistency_test.py 小数位一致性检测
|
||||
│ └── image_duplicate_test.py 图像重复检测
|
||||
├── docs/ 完整文档
|
||||
│ ├── USAGE_GUIDE.md 多平台使用指南(Claude/Cursor/GPT/Codex 等)
|
||||
│ ├── DATA_SOURCES.md 学术参考文献 + 数据标准 + 伦理合规
|
||||
│ ├── ANNOTATIONS.md 架构图 + API 接口 + 代码注释规范
|
||||
│ └── EXAMPLE_WALKTHROUGH.md 端到端完整教程
|
||||
├── examples/ 示例数据
|
||||
├── tests/ 单元测试(24/24 通过)
|
||||
└── assets/ README 配图
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [USAGE_GUIDE.md](docs/USAGE_GUIDE.md) | 在 Claude / Cursor / GPT / Codex / Jupyter / Docker 等平台上的使用方法 |
|
||||
| [DATA_SOURCES.md](docs/DATA_SOURCES.md) | 学术参考文献、数据标准、伦理合规框架 |
|
||||
| [ANNOTATIONS.md](docs/ANNOTATIONS.md) | 系统架构、API 接口规范、代码注释标准 |
|
||||
| [EXAMPLE_WALKTHROUGH.md](docs/EXAMPLE_WALKTHROUGH.md) | 从一篇论文到检测报告的完整教程 |
|
||||
| [SKILL.md](SKILL.md) | Skill 核心技术文档 |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 学术参考
|
||||
|
||||
1. Benford, F. (1938). The law of anomalous numbers. *Proc. APS*, 78(4), 551–572.
|
||||
2. Brown, N.J.L. & Heathers, J.A.J. (2017). The GRIM Test. *SPPS*, 8(4), 363–369.
|
||||
3. Bik, E.M. et al. (2016). Image duplication in biomedical research. *mBio*, 7(3).
|
||||
4. Nigrini, M.J. (2012). *Benford's Law*. Wiley. ISBN: 978-1118152850.
|
||||
5. 余菁等 (2021). 科技论文数据造假的核查策略. *中国科技期刊研究*, 32(6), 770–776.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
本项目的灵感来源于 **"耿同学讲故事"** —— 一位吉林大学生物学硕士、北航退学博士,从 2026 年 4 月开始,仅凭一台电脑和统计学方法,就揪出了多所顶尖高校教授的论文数据造假。他的工作证明了:**学术诚信监督不仅必要,而且完全可行。**
|
||||
|
||||
> "如果论文里的数据存在规律性,那么就明显不是在实验室实际测量的情况下生成的。"
|
||||
>
|
||||
> —— 耿同学
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源许可
|
||||
|
||||
MIT License — 详见 [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<em>让学术回归诚信,让数据说出真相。</em><br>
|
||||
<em>Let academic integrity prevail. Let data speak the truth.</em>
|
||||
</p>
|
||||
319
tools/gengskill/README_CN.md
Normal file
319
tools/gengskill/README_CN.md
Normal file
@ -0,0 +1,319 @@
|
||||
<p align="center">
|
||||
<img src="assets/banner.png" alt="Geng Skill Banner" width="100%">
|
||||
</p>
|
||||
|
||||
<h1 align="center">🔬 Geng Skill — 学术数据打假检测工具</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>用数据说话,让造假无所遁形 · Inspired by "耿同学讲故事"</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.8%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.8+">
|
||||
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=for-the-badge" alt="MIT License">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.0-7c3aed?style=for-the-badge" alt="Version 2.0.0">
|
||||
<img src="https://img.shields.io/badge/Tests-24%2F24_Passed-22c55e?style=for-the-badge" alt="Tests Passing">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-快速开始">Quick Start</a> •
|
||||
<a href="#-工作原理">How It Works</a> •
|
||||
<a href="#-三种输入模式">Input Modes</a> •
|
||||
<a href="#-检测模块">Detection Modules</a> •
|
||||
<a href="#-实战案例">Example</a> •
|
||||
<a href="#-文档">Documentation</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 这是什么?
|
||||
|
||||
**Geng Skill** 是一套基于统计学原理的学术论文数据造假检测工具包。
|
||||
|
||||
2026 年 4 月起,科普博主"耿同学讲故事"凭一台电脑和几个统计方法,连续揪出多所 985 高校顶尖学者的论文造假——同济大学 Nature 论文院长免职、南开大学 Nature 子刊正在调查……他证明了一件事:**造假的数据一定会留下统计学破绽。**
|
||||
|
||||
本项目将"耿同学"的技术方法论系统化、工具化,让任何人都能一键检测论文数据是否存在造假嫌疑。
|
||||
|
||||
> 💡 **核心原理**:真实实验数据具有随机性;人为编造的数据会呈现不自然的数学规律。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/YOUR_USERNAME/geng-skill.git
|
||||
cd geng-skill
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 一键自动扫描(Scale 模式)
|
||||
python3 scripts/input_pipeline.py --input your_data.csv --mode scale
|
||||
```
|
||||
|
||||
就这么简单。Scale 模式会**自动扫描所有数值列**,运行 6 种检测算法,并按嫌疑程度从高到低排列结果。
|
||||
|
||||
---
|
||||
|
||||
## 🧠 工作原理
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/architecture.png" alt="System Architecture" width="90%">
|
||||
</p>
|
||||
|
||||
系统分为三层:
|
||||
|
||||
### 第一层 · 数据输入 Input Pipeline
|
||||
|
||||
支持 **PDF 论文**、**Excel 原始数据**、**CSV 表格** 三种格式。自动提取表格、识别数值列、标准化数据格式。
|
||||
|
||||
### 第二层 · 检测引擎 Detection Engine
|
||||
|
||||
6 个独立的统计检测模块,从不同角度分析数据异常:
|
||||
|
||||
| 模块 | 检测目标 | 方法 |
|
||||
|------|----------|------|
|
||||
| **末位数字检测** Last Digit | 末位数字集中度异常 | Chi-squared vs 均匀分布 |
|
||||
| **本福特定律** Benford's Law | 首位数字分布偏离 | Chi-squared + MAD |
|
||||
| **GRIM 测试** | 均值与样本量不兼容 | 离散粒度校验 |
|
||||
| **固定关系检测** Fixed Ratio ⭐ | 实验组间存在完美数学关系 | 比值/回归分析 |
|
||||
| **小数位一致性** Decimal | 小数部分模式重复 | 自相关 + 熵分析 |
|
||||
| **图像重复检测** Image Dup | 同一图片在不同条件下重复使用 | 感知哈希 + SSIM |
|
||||
|
||||
### 第三层 · 输出报告 Output
|
||||
|
||||
生成出版级可视化图表、综合风险评分(0–100)、以及详细的 HTML/Markdown 检测报告,精确标注每个可疑数据点。
|
||||
|
||||
---
|
||||
|
||||
## 📥 三种输入模式
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/workflow.png" alt="Workflow" width="90%">
|
||||
</p>
|
||||
|
||||
### 模式一:论文 PDF 输入
|
||||
|
||||
直接从 PDF 论文中提取数据表格:
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input paper.pdf --mode extract
|
||||
```
|
||||
|
||||
### 模式二:Excel / CSV 原始数据
|
||||
|
||||
处理从论文下载的 Supplementary Data:
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input supplementary_data.xlsx --mode extract
|
||||
python3 scripts/input_pipeline.py --input table_s1.csv --mode extract
|
||||
```
|
||||
|
||||
### 模式三:Scale 自动扫描 ⭐(推荐)
|
||||
|
||||
**让工具自己去找问题。** 无需指定检测哪些列、用哪些方法——全部自动:
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input data.csv --mode scale
|
||||
```
|
||||
|
||||
Scale 模式会:
|
||||
1. 自动识别所有数值列
|
||||
2. 运行 6 大检测模块
|
||||
3. 交叉验证各模块结果
|
||||
4. 输出 **suspicion_ranking**(嫌疑排名榜)
|
||||
|
||||
---
|
||||
|
||||
## 🔬 检测模块
|
||||
|
||||
### ⭐ 固定关系检测(核心 · 耿同学的看家本领)
|
||||
|
||||
这是最致命的检测手段。如果两组"独立实验"数据之间存在**完美的固定数学关系**(比如每个样本 Treatment = Control × 2.0),那几乎可以断定是造假。
|
||||
|
||||
```python
|
||||
from fixed_relation_test import fixed_relation_test
|
||||
|
||||
result = fixed_relation_test(control_data, treatment_data)
|
||||
# result['risk_score'] = 95 → 检测到精确 2.0 倍关系!
|
||||
```
|
||||
|
||||
**为什么这能定性?** 即使药物真的让蛋白表达提高 2 倍,每个样本也会有生物学个体差异。20 个样本**全部**精确到小数点后多位都是 2.000 倍——概率趋近于零。
|
||||
|
||||
### 本福特定律检测 Benford's Law
|
||||
|
||||
跨越多个数量级的自然数据,首位数字遵循特定概率分布(1 最多,9 最少)。人为编造的数据会偏离:
|
||||
|
||||
```python
|
||||
from benford_test import benford_test
|
||||
result = benford_test(values)
|
||||
```
|
||||
|
||||
### GRIM 测试
|
||||
|
||||
对于整数取值的数据(如李克特量表 1–5 分),给定样本量 n,并非所有均值都是数学上可能的:
|
||||
|
||||
```python
|
||||
from grim_test import grim_test_single
|
||||
result = grim_test_single(mean='3.47', n=25, decimals=2)
|
||||
# result['consistent'] = False → 这个均值是不可能存在的!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 实战案例
|
||||
|
||||
### 输入:一篇可疑的生物医学论文数据
|
||||
|
||||
论文声称对小鼠进行了三组独立实验(Control / Treatment A / Treatment B),数据如下:
|
||||
|
||||
```csv
|
||||
sample_id,control,treatment_a,treatment_b
|
||||
1,2.34,4.68,7.02
|
||||
2,3.12,6.24,9.36
|
||||
3,1.87,3.74,5.61
|
||||
4,4.56,9.12,13.68
|
||||
5,2.98,5.96,8.94
|
||||
...
|
||||
```
|
||||
|
||||
### 运行 Scale 模式自动检测
|
||||
|
||||
```bash
|
||||
python3 scripts/input_pipeline.py --input data.csv --mode scale
|
||||
```
|
||||
|
||||
### 输出:精准定位问题
|
||||
|
||||
```
|
||||
=== Scale Mode: 自动检测结果 ===
|
||||
|
||||
🔴 [1.00] control ↔ treatment_a ← 精确固定比值 = 2.000
|
||||
🔴 [1.00] control ↔ treatment_b ← 精确固定比值 = 3.000
|
||||
🔴 [1.00] treatment_a ↔ treatment_b ← 精确固定比值 = 1.500
|
||||
🟠 [0.99] treatment_a ← 末位数字分布异常
|
||||
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ 综合风险评分: 92/100 🔴 极高风险 ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ 三组"独立实验"数据之间存在完美的整数倍关系。 ║
|
||||
║ 在真实生物实验中,这种情况出现的概率约等于零。 ║
|
||||
║ 数据极大概率为人工编造。 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 风险评分体系
|
||||
|
||||
| 分数 | 等级 | 含义 | 建议行动 |
|
||||
|------|------|------|----------|
|
||||
| 0–25 | 🟢 低风险 | 未发现异常 | 无需干预 |
|
||||
| 26–50 | 🟡 中等 | 存在轻微模式,可能是正常波动 | 建议复核 |
|
||||
| 51–75 | 🟠 高风险 | 多项指标异常 | 深入调查 |
|
||||
| 76–100 | 🔴 极高风险 | 系统性异常 | 正式举报 |
|
||||
|
||||
**置信度规则:**
|
||||
- 单一模块报警 → 标注为"待确认线索"
|
||||
- 2 个以上独立模块同时报警 → 标注为"高度可疑"
|
||||
- 仅当综合分 > 75 **且**多模块交叉确认时,才标注"极高风险"
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ 准确性与免责声明
|
||||
|
||||
### ✅ 本工具能做什么
|
||||
|
||||
- 检测不同数据列之间的固定数学关系
|
||||
- 识别数字分布的统计学异常
|
||||
- 标记数学上不可能的统计报告值
|
||||
- 发现重复使用/篡改的论文图片
|
||||
- 提供量化的风险评估和置信度等级
|
||||
|
||||
### ❌ 本工具不能做什么
|
||||
|
||||
- 不能证明造假的主观意图
|
||||
- 不能检测加了随机噪声的"高明造假"
|
||||
- 不能替代领域专家的判断
|
||||
- 不具备法律效力
|
||||
|
||||
### ⚠️ 重要声明
|
||||
|
||||
> **本工具仅提供统计学层面的异常筛查功能。**
|
||||
> 输出结果为"疑点线索"(Suspicious Indicators),而非"造假判定"(Fraud Determination)。
|
||||
>
|
||||
> - 统计异常可能有合理的科学解释(仪器精度限制、数据标准化处理、单位转换等)
|
||||
> - 最终判定需要领域专家复核和正式调查程序
|
||||
> - 通过全部检测 ≠ 数据一定真实(某些造假无法被统计方法捕获)
|
||||
> - 使用者需自行承担因不当使用(如公开发布未经验证的指控)造成的一切后果
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
geng-skill/
|
||||
├── scripts/ 核心引擎
|
||||
│ ├── input_pipeline.py 统一输入(PDF/Excel/CSV + Scale 模式)
|
||||
│ ├── visualization.py 出版级可视化图表
|
||||
│ ├── report_generator.py HTML + Markdown 报告生成
|
||||
│ ├── geng_assess.py 综合评估引擎
|
||||
│ ├── last_digit_test.py 末位数字检测
|
||||
│ ├── benford_test.py 本福特定律检测
|
||||
│ ├── grim_test.py GRIM 均值一致性测试
|
||||
│ ├── fixed_relation_test.py 固定关系检测 ⭐
|
||||
│ ├── decimal_consistency_test.py 小数位一致性检测
|
||||
│ └── image_duplicate_test.py 图像重复检测
|
||||
├── docs/ 完整文档
|
||||
│ ├── USAGE_GUIDE.md 多平台使用指南(Claude/Cursor/GPT/Codex 等)
|
||||
│ ├── DATA_SOURCES.md 学术参考文献 + 数据标准 + 伦理合规
|
||||
│ ├── ANNOTATIONS.md 架构图 + API 接口 + 代码注释规范
|
||||
│ └── EXAMPLE_WALKTHROUGH.md 端到端完整教程
|
||||
├── examples/ 示例数据
|
||||
├── tests/ 单元测试(24/24 通过)
|
||||
└── assets/ README 配图
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [USAGE_GUIDE.md](docs/USAGE_GUIDE.md) | 在 Claude / Cursor / GPT / Codex / Jupyter / Docker 等平台上的使用方法 |
|
||||
| [DATA_SOURCES.md](docs/DATA_SOURCES.md) | 学术参考文献、数据标准、伦理合规框架 |
|
||||
| [ANNOTATIONS.md](docs/ANNOTATIONS.md) | 系统架构、API 接口规范、代码注释标准 |
|
||||
| [EXAMPLE_WALKTHROUGH.md](docs/EXAMPLE_WALKTHROUGH.md) | 从一篇论文到检测报告的完整教程 |
|
||||
| [SKILL.md](SKILL.md) | Skill 核心技术文档 |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 学术参考
|
||||
|
||||
1. Benford, F. (1938). The law of anomalous numbers. *Proc. APS*, 78(4), 551–572.
|
||||
2. Brown, N.J.L. & Heathers, J.A.J. (2017). The GRIM Test. *SPPS*, 8(4), 363–369.
|
||||
3. Bik, E.M. et al. (2016). Image duplication in biomedical research. *mBio*, 7(3).
|
||||
4. Nigrini, M.J. (2012). *Benford's Law*. Wiley. ISBN: 978-1118152850.
|
||||
5. 余菁等 (2021). 科技论文数据造假的核查策略. *中国科技期刊研究*, 32(6), 770–776.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
本项目的灵感来源于 **"耿同学讲故事"**,从 2026 年 4 月开始,仅凭一台电脑和统计学方法,就揪出了多所顶尖高校教授的论文数据造假。他的工作证明了:**学术诚信监督不仅必要,而且完全可行。**
|
||||
|
||||
> "如果论文里的数据存在规律性,那么就明显不是在实验室实际测量的情况下生成的。"
|
||||
>
|
||||
> —— 耿同学
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源许可
|
||||
|
||||
MIT License — 详见 [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<em>让学术回归诚信,让数据说出真相。</em><br>
|
||||
<em>Let academic integrity prevail. Let data speak the truth.</em>
|
||||
</p>
|
||||
139
tools/gengskill/SKILL.md
Normal file
139
tools/gengskill/SKILL.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Geng Skill — 学术数据打假检测工具
|
||||
|
||||
> 致敬"耿同学讲故事"——用数据说话,让造假无所遁形。
|
||||
|
||||
## 概述
|
||||
|
||||
本 Skill 实现了一套**基于统计学原理的学术论文数据造假检测方法**,灵感来源于科普博主"耿同学"的技术流打假方法论。该工具从数据层面对论文中的实验数据进行多维度异常检测,适用于生物医学、化学、物理、社会科学等多个领域。
|
||||
|
||||
## 核心原理
|
||||
|
||||
**自然数据具有随机性,人为编造的数据会呈现不自然的规律性。**
|
||||
|
||||
当研究者伪造实验数据时,由于人脑无法真正生成随机数,编造的数据往往会暴露以下统计学破绽:
|
||||
|
||||
1. **末位数字分布异常** — 自然数据末位数字应近似均匀分布
|
||||
2. **固定差值/比例关系** — 不同实验组数据间存在恒定数学关系
|
||||
3. **小数位一致性过高** — 多组数据小数点后位数高度一致
|
||||
4. **本福特定律偏离** — 首位数字分布严重偏离理论预期
|
||||
5. **GRIM/SPRITE 不一致** — 报告的平均值与样本量不兼容
|
||||
6. **图像重复/篡改** — 同一图片出现在不同实验条件下
|
||||
|
||||
## 适用领域
|
||||
|
||||
| 领域 | 检测重点 | 典型数据类型 |
|
||||
|------|----------|--------------|
|
||||
| 生物医学 | Western blot、流式细胞术、动物实验数据 | 连续测量值、图像 |
|
||||
| 化学 | 光谱数据、反应产率、催化活性 | 数值序列 |
|
||||
| 物理/材料 | 性能曲线、电学/力学测试数据 | 时间序列 |
|
||||
| 社会科学 | 问卷数据、量表得分 | 离散整数值 |
|
||||
| 临床医学 | 生存数据、临床指标 | 分组统计量 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 输入
|
||||
|
||||
- 论文 PDF 文件(或提取的数据表格)
|
||||
- 补充材料 / Source Data(如有)
|
||||
- 指定检测领域(用于选择合适的检测策略)
|
||||
|
||||
### 检测流程
|
||||
|
||||
```
|
||||
输入论文 → 数据提取 → 多维度异常检测 → 综合评分 → 生成报告
|
||||
```
|
||||
|
||||
### 输出
|
||||
|
||||
- **异常检测报告**:每项检测的结果、p值、置信度
|
||||
- **综合风险评分**:0-100 分,分为低/中/高/极高风险
|
||||
- **可视化图表**:分布直方图、偏离热力图
|
||||
- **建议行动**:需要进一步核查的具体数据点
|
||||
|
||||
## 检测模块
|
||||
|
||||
### Module 1: 末位数字检测 (Last Digit Test)
|
||||
|
||||
```bash
|
||||
python3 scripts/last_digit_test.py --input data.csv --column "value"
|
||||
```
|
||||
|
||||
原理:自然实验数据的末位数字(0-9)应近似均匀分布。卡方检验判断偏离程度。
|
||||
|
||||
### Module 2: 本福特定律检测 (Benford's Law Test)
|
||||
|
||||
```bash
|
||||
python3 scripts/benford_test.py --input data.csv --column "value"
|
||||
```
|
||||
|
||||
原理:多数量级跨度的自然数据,首位数字以1开头的概率约30.1%,逐位递减至9的4.6%。
|
||||
|
||||
### Module 3: GRIM 测试 (Granularity-Related Inconsistency of Means)
|
||||
|
||||
```bash
|
||||
python3 scripts/grim_test.py --mean 3.47 --n 25 --scale "1-5" --decimals 2
|
||||
```
|
||||
|
||||
原理:对于整数取值数据,给定样本量 n,合法的平均值只能取特定的有限集合。
|
||||
|
||||
### Module 4: 固定关系检测 (Fixed Relationship Detection)
|
||||
|
||||
```bash
|
||||
python3 scripts/fixed_relation_test.py --input data.csv --col1 "group_a" --col2 "group_b"
|
||||
```
|
||||
|
||||
原理:两组独立实验数据之间不应存在恒定的差值、比值或线性关系。
|
||||
|
||||
### Module 5: 小数位一致性检测 (Decimal Consistency Test)
|
||||
|
||||
```bash
|
||||
python3 scripts/decimal_consistency_test.py --input data.csv --column "value"
|
||||
```
|
||||
|
||||
原理:实验测量数据的小数位后数字应具有随机性,过度一致暗示人为编造。
|
||||
|
||||
### Module 6: 图像重复检测 (Image Duplication Detection)
|
||||
|
||||
```bash
|
||||
python3 scripts/image_duplicate_test.py --input_dir ./figures/ --threshold 0.85
|
||||
```
|
||||
|
||||
原理:基于感知哈希和 SSIM 相似度,检测论文图片中是否存在重复使用或篡改。
|
||||
|
||||
### Module 7: 综合评估引擎 (Comprehensive Assessment)
|
||||
|
||||
```bash
|
||||
python3 scripts/geng_assess.py --input data.csv --domain "biomedical" --output report/
|
||||
```
|
||||
|
||||
一键运行所有适用模块,生成综合报告。
|
||||
|
||||
## 风险评分体系
|
||||
|
||||
| 等级 | 分数 | 含义 |
|
||||
|------|------|------|
|
||||
| 🟢 低风险 | 0-25 | 数据未发现明显异常 |
|
||||
| 🟡 中风险 | 26-50 | 存在可疑模式,建议人工复核 |
|
||||
| 🟠 高风险 | 51-75 | 多项检测异常,强烈建议深入调查 |
|
||||
| 🔴 极高风险 | 76-100 | 系统性异常,高度疑似数据造假 |
|
||||
|
||||
## 重要声明
|
||||
|
||||
⚠️ **本工具仅用于辅助筛查,不能作为造假的最终判定依据。**
|
||||
|
||||
- 数据异常 ≠ 数据造假(可能是仪器校准、单位转换、排版错误等)
|
||||
- 检测结果需要领域专家复核
|
||||
- 不应基于单一检测模块的结果下结论
|
||||
- 使用本工具时应遵守学术伦理和法律法规
|
||||
- 建议将检测结果提交给相关机构进行正式调查
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. Benford, F. (1938). The law of anomalous numbers. *Proceedings of the American Philosophical Society*, 78(4), 551-572.
|
||||
2. Brown, N.J.L., & Heathers, J.A.J. (2017). The GRIM Test: A Simple Technique Detects Numerous Anomalies in the Reporting of Results in Psychology. *Social Psychological and Personality Science*, 8(4), 363-369.
|
||||
3. 余菁等 (2021). 科技论文数据造假的核查策略和统计学方法验证. *中国科技期刊研究*, 32(6), 770-776.
|
||||
4. Bik, E.M., et al. (2016). The prevalence of inappropriate image duplication in biomedical research publications. *mBio*, 7(3), e00809-16.
|
||||
|
||||
## 版本
|
||||
|
||||
- v1.0.0 — 2026-05-20 — 初始版本,致敬耿同学
|
||||
BIN
tools/gengskill/assets/architecture.png
Normal file
BIN
tools/gengskill/assets/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1003 KiB |
BIN
tools/gengskill/assets/banner.png
Normal file
BIN
tools/gengskill/assets/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
tools/gengskill/assets/workflow.png
Normal file
BIN
tools/gengskill/assets/workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1003 KiB |
514
tools/gengskill/docs/ANNOTATIONS.md
Normal file
514
tools/gengskill/docs/ANNOTATIONS.md
Normal file
@ -0,0 +1,514 @@
|
||||
# 🏷️ Geng Skill 代码注释规范与架构说明
|
||||
|
||||
> 本文档提供完整的代码注释体系、模块间关系、接口规范,供开发者和 AI Agent 使用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ geng_assess.py │
|
||||
│ (综合评估引擎 / 主入口) │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌───────────┬───────────┼───────────┬───────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────────┐
|
||||
│last_digit │ │ benford │ │ grim │ │fixed_rel │ │decimal_cons │
|
||||
│_test.py │ │_test.py │ │_test.py │ │_test.py │ │_test.py │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│末位数字检测│ │本福特定律 │ │均值一致性 │ │固定关系检测│ │小数位一致性 │
|
||||
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └──────────────┘
|
||||
│ │
|
||||
│ ┌────────────────┐ │
|
||||
└───────────▶│image_duplicate │◀─────────────┘
|
||||
│_test.py │
|
||||
│图像重复检测 │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块接口规范 (API Contract)
|
||||
|
||||
### 2.1 通用接口模式
|
||||
|
||||
每个检测模块都遵循统一的函数签名模式:
|
||||
|
||||
```python
|
||||
def <module_name>_test(
|
||||
values: List[str | float], # 输入数据
|
||||
**kwargs # 模块特定参数
|
||||
) -> Dict[str, Any]: # 标准化输出
|
||||
"""
|
||||
[模块名称] — [一句话描述]
|
||||
|
||||
Parameters
|
||||
----------
|
||||
values : list
|
||||
待检测数据。字符串形式传入以保留原始精度。
|
||||
**kwargs : dict
|
||||
模块特定参数(详见各模块文档)
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
标准化输出,必含字段:
|
||||
- test_name : str — 模块名称(中英文)
|
||||
- status : str — "completed" | "insufficient_data" | "error"
|
||||
- risk_level : str — "low" | "medium" | "medium-high" | "high"
|
||||
- risk_score : float — 0-100 风险评分
|
||||
- interpretation : str — 中文可读解释
|
||||
"""
|
||||
```
|
||||
|
||||
### 2.2 各模块特定接口
|
||||
|
||||
#### Module 1: `last_digit_test()`
|
||||
|
||||
```python
|
||||
def last_digit_test(
|
||||
values: List[str],
|
||||
method: str = 'all_digits' # 'all_digits' | 'decimal_last'
|
||||
) -> Dict:
|
||||
"""
|
||||
末位数字检测
|
||||
|
||||
特定输出字段:
|
||||
- digit_distribution : Dict[str, int] — 0-9 各数字出现次数
|
||||
- chi_square : float — 卡方统计量
|
||||
- p_value : float — p值
|
||||
- most_frequent_digit : int — 出现最多的数字
|
||||
- most_frequent_proportion : float — 最高频率
|
||||
- uniformity_deviation : float — 偏离均匀度 (0-1)
|
||||
"""
|
||||
```
|
||||
|
||||
#### Module 2: `benford_test()`
|
||||
|
||||
```python
|
||||
def benford_test(
|
||||
values: List[str],
|
||||
order: int = 1 # 1=首位, 2=前两位
|
||||
) -> Dict:
|
||||
"""
|
||||
本福特定律检测
|
||||
|
||||
前提条件: 数据应跨越至少1个数量级
|
||||
|
||||
特定输出字段:
|
||||
- distribution : Dict[str, Dict] — 各位数字观测/期望频率
|
||||
- mean_absolute_deviation : float — MAD (Nigrini 判定标准)
|
||||
- conformity : str — 'close'|'acceptable'|'marginal'|'nonconforming'
|
||||
- conformity_cn : str — 中文符合性判定
|
||||
"""
|
||||
```
|
||||
|
||||
#### Module 3: `grim_test_single()` / `grim_test_batch()`
|
||||
|
||||
```python
|
||||
def grim_test_single(
|
||||
mean: str, # 报告的平均值(字符串保留精度)
|
||||
n: int, # 样本量
|
||||
decimals: int = 2, # 报告的小数位数
|
||||
scale_min: int = None, # 量表下限
|
||||
scale_max: int = None # 量表上限
|
||||
) -> Dict:
|
||||
"""
|
||||
GRIM 单项测试
|
||||
|
||||
特定输出字段:
|
||||
- consistent : bool — 是否通过一致性检验
|
||||
- computed_sum : float — 计算的总和 (mean × n)
|
||||
- nearest_valid_mean : str — 最近的合法均值
|
||||
- difference : float — 与最近合法均值的差距
|
||||
"""
|
||||
|
||||
def grim_test_batch(
|
||||
items: List[Dict] # 批量项目列表
|
||||
) -> Dict:
|
||||
"""
|
||||
GRIM 批量测试
|
||||
|
||||
items 格式: [{"mean": "3.47", "n": 25, "decimals": 2, "label": "Table 1"}, ...]
|
||||
|
||||
特定输出字段:
|
||||
- total_items : int
|
||||
- inconsistent_items : int
|
||||
- inconsistency_rate : float
|
||||
- details : List[Dict] — 每项的详细结果
|
||||
"""
|
||||
```
|
||||
|
||||
#### Module 4: `fixed_relation_test()`
|
||||
|
||||
```python
|
||||
def fixed_relation_test(
|
||||
col1: List[float], # 第一列数据
|
||||
col2: List[float], # 第二列数据
|
||||
col1_name: str = 'A', # 列名标签
|
||||
col2_name: str = 'B' # 列名标签
|
||||
) -> Dict:
|
||||
"""
|
||||
固定关系检测 — ⭐ 核心模块(耿同学最常用的方法)
|
||||
|
||||
检测内容:
|
||||
1. 固定差值 (col2 - col1 = 常数?)
|
||||
2. 固定比值 (col2 / col1 = 常数?)
|
||||
3. 完美线性关系 (R² → 1.0?)
|
||||
4. 小数模式一致性
|
||||
|
||||
特定输出字段:
|
||||
- detections : Dict — 各子检测结果
|
||||
- fixed_difference : {is_fixed, is_exact, mean_difference, std_difference}
|
||||
- fixed_ratio : {is_fixed, is_exact, mean_ratio, std_ratio}
|
||||
- linear_relationship : {r_squared, slope, intercept, is_suspicious}
|
||||
- decimal_pattern : {match_rate, is_suspicious}
|
||||
- n_suspicious_patterns : int
|
||||
"""
|
||||
```
|
||||
|
||||
#### Module 5: `decimal_consistency_test()`
|
||||
|
||||
```python
|
||||
def decimal_consistency_test(
|
||||
values: List[str] # 保留原始字符串精度
|
||||
) -> Dict:
|
||||
"""
|
||||
小数位一致性检测
|
||||
|
||||
特定输出字段:
|
||||
- decimal_places_analysis : Dict — 小数位数分布
|
||||
- decimal_repetition : Dict — 小数模式重复度
|
||||
- position_digit_analysis : Dict — 各位数字分布检验
|
||||
- autocorrelation : float — 小数部分自相关
|
||||
- risk_factors : List[str] — 触发的风险因子
|
||||
"""
|
||||
```
|
||||
|
||||
#### Module 6: `find_duplicates()`
|
||||
|
||||
```python
|
||||
def find_duplicates(
|
||||
image_dir: str, # 图片目录
|
||||
threshold: float = 0.85, # 相似度阈值
|
||||
extensions: List[str] = None # 图片格式
|
||||
) -> Dict:
|
||||
"""
|
||||
图像重复检测
|
||||
|
||||
依赖: Pillow, scikit-image (可选, 用于SSIM)
|
||||
|
||||
特定输出字段:
|
||||
- n_images_scanned : int
|
||||
- n_duplicate_pairs : int
|
||||
- duplicates : List[Dict] — 每对疑似重复图片
|
||||
- file_1, file_2 : str
|
||||
- avg_hash_similarity : float
|
||||
- diff_hash_similarity : float
|
||||
- combined_similarity : float
|
||||
- rotation_check : Dict — 旋转/翻转匹配结果
|
||||
- ssim : float (如果 scikit-image 可用)
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码注释规范
|
||||
|
||||
### 3.1 文件头注释模板
|
||||
|
||||
每个 Python 文件必须包含以下格式的文件头:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
[模块名称中文] ([Module Name English])
|
||||
{'='*len(module_name)}
|
||||
|
||||
原理:[一段话描述检测原理]
|
||||
|
||||
方法:[具体使用的统计方法]
|
||||
|
||||
参考:[关键参考文献,一行一条]
|
||||
|
||||
致敬"耿同学讲故事" — 用数据说话,让造假无所遁形。
|
||||
"""
|
||||
```
|
||||
|
||||
### 3.2 函数注释规范 (NumPy Style)
|
||||
|
||||
```python
|
||||
def function_name(param1, param2, param3=default):
|
||||
"""
|
||||
[一句话功能描述]
|
||||
|
||||
[详细说明段落,解释为什么需要这个函数、在什么场景下使用]
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param1 : type
|
||||
参数说明
|
||||
param2 : type
|
||||
参数说明
|
||||
param3 : type, optional
|
||||
参数说明(默认值:default)
|
||||
|
||||
Returns
|
||||
-------
|
||||
return_type
|
||||
返回值说明
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
何时抛出此异常
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> result = function_name([1, 2, 3])
|
||||
>>> print(result['risk_score'])
|
||||
15.3
|
||||
|
||||
Notes
|
||||
-----
|
||||
[重要注意事项、使用限制、已知问题]
|
||||
|
||||
References
|
||||
----------
|
||||
[1] Author (Year). Title. Journal. DOI.
|
||||
"""
|
||||
```
|
||||
|
||||
### 3.3 行内注释规范
|
||||
|
||||
```python
|
||||
# ✅ 好的注释 — 解释"为什么"
|
||||
# 本福特定律只适用于跨数量级的数据,pH值(0-14)不适用
|
||||
if value_range < 10:
|
||||
return skip_benford()
|
||||
|
||||
# ❌ 差的注释 — 重复代码
|
||||
# 计算平均值
|
||||
mean = sum(values) / len(values)
|
||||
|
||||
# ✅ 好的注释 — 标注算法来源
|
||||
# MAD 阈值参考 Nigrini (2012), Table 7.1
|
||||
# Close conformity: MAD < 0.006
|
||||
MAD_THRESHOLD_CLOSE = 0.006
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与边界条件
|
||||
|
||||
### 4.1 标准错误返回
|
||||
|
||||
```python
|
||||
# 数据不足
|
||||
if len(values) < MIN_REQUIRED:
|
||||
return {
|
||||
'status': 'insufficient_data',
|
||||
'message': f'数据量不足(仅{len(values)}个),需要至少{MIN_REQUIRED}个',
|
||||
'n_valid': len(values)
|
||||
}
|
||||
|
||||
# 输入格式错误
|
||||
if not valid_input:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'无效输入: {error_detail}'
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 边界条件处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 全部值为0 | 跳过本福特检测(返回 status='not_applicable') |
|
||||
| 无小数部分 | 跳过小数位检测 |
|
||||
| 仅1列数值 | 跳过固定关系检测 |
|
||||
| 图片目录为空 | 返回 insufficient_data |
|
||||
| 极端离群值 | 不剔除,但在 notes 中标注 |
|
||||
| NaN/无效值 | 静默跳过,在 n_valid 中反映 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险评分算法详解
|
||||
|
||||
### 5.1 单模块评分
|
||||
|
||||
```python
|
||||
"""
|
||||
风险评分映射逻辑(以末位数字检测为例):
|
||||
|
||||
p >= 0.05 → risk_score = 40 * (1 - p) ∈ [0, ~38] → "low"
|
||||
0.01 <= p < 0.05 → risk_score = 40 + ... ∈ [40, 60] → "medium"
|
||||
0.001 <= p < 0.01 → risk_score = 60 + ... ∈ [60, 80] → "medium-high"
|
||||
p < 0.001 → risk_score = 80 + ... ∈ [80, 100] → "high"
|
||||
|
||||
设计考量:
|
||||
- 不直接使用 1-p 作为分数(会导致 p=0.04 和 p=0.06 差距过小)
|
||||
- 分段线性映射,确保跨越统计显著性阈值时有明显跳变
|
||||
- 上限 100 永远不精确达到(留有余地表示"不确定性")
|
||||
"""
|
||||
```
|
||||
|
||||
### 5.2 综合评分算法
|
||||
|
||||
```python
|
||||
"""
|
||||
综合评分 = 0.6 × max(各模块分数) + 0.4 × mean(各模块分数)
|
||||
|
||||
设计理由:
|
||||
- 加权最大值确保"只要有一个模块高度异常,综合分就不会太低"
|
||||
- 加权平均确保"如果多个模块都略有异常,综合分会累积上升"
|
||||
- 0.6/0.4 比例经验性确定,偏向保守(避免漏检重于避免误报)
|
||||
|
||||
特殊规则:
|
||||
- 如果固定关系检测发现 is_exact=True,直接 risk_score = max(score, 90)
|
||||
- 如果图像检测发现 similarity > 0.98,直接 risk_score = max(score, 90)
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试用例规范
|
||||
|
||||
### 6.1 单元测试结构
|
||||
|
||||
```python
|
||||
# tests/test_modules.py
|
||||
|
||||
"""
|
||||
测试策略:
|
||||
1. 已知正常数据 → 应返回 low risk
|
||||
2. 已知造假数据 → 应返回 high risk
|
||||
3. 边界条件 → 应优雅处理
|
||||
4. 回归测试 → 固定输入,固定输出
|
||||
"""
|
||||
|
||||
def test_last_digit_uniform_data():
|
||||
"""均匀分布数据应返回低风险"""
|
||||
import random
|
||||
random.seed(42)
|
||||
values = [str(random.uniform(1, 100)) for _ in range(100)]
|
||||
result = last_digit_test(values)
|
||||
assert result['risk_level'] == 'low'
|
||||
assert result['risk_score'] < 30
|
||||
|
||||
def test_fixed_relation_exact_ratio():
|
||||
"""精确固定比值应返回极高风险"""
|
||||
col1 = [1.23, 2.34, 3.45, 4.56, 5.67]
|
||||
col2 = [2.46, 4.68, 6.90, 9.12, 11.34] # 精确 ×2
|
||||
result = fixed_relation_test(col1, col2)
|
||||
assert result['risk_level'] == 'high'
|
||||
assert result['risk_score'] >= 85
|
||||
|
||||
def test_grim_consistent():
|
||||
"""合法均值应通过 GRIM"""
|
||||
# n=20, 整数数据, mean=3.40 → sum=68 ✓
|
||||
result = grim_test_single('3.40', 20, decimals=2)
|
||||
assert result['consistent'] == True
|
||||
|
||||
def test_grim_inconsistent():
|
||||
"""非法均值应失败"""
|
||||
# n=20, 整数数据, mean=3.47 → sum=69.4 ✗
|
||||
result = grim_test_single('3.47', 20, decimals=2)
|
||||
assert result['consistent'] == False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AI Agent 集成注释
|
||||
|
||||
### 7.1 Prompt Engineering 标注
|
||||
|
||||
每个模块的 docstring 设计为可被 AI Agent 直接解析:
|
||||
|
||||
```python
|
||||
"""
|
||||
[AGENT_INSTRUCTION]
|
||||
当用户要求检测数据造假时,按以下优先级选择模块:
|
||||
1. 如果用户提供了两组"应该独立"的数据 → fixed_relation_test()
|
||||
2. 如果数据跨越多个数量级 → benford_test()
|
||||
3. 如果数据含小数 → decimal_consistency_test() + last_digit_test()
|
||||
4. 如果用户提供了均值和样本量 → grim_test_single()
|
||||
5. 如果有图片文件 → find_duplicates()
|
||||
6. 一键全检 → geng_assess.py
|
||||
|
||||
[AGENT_OUTPUT_FORMAT]
|
||||
向用户展示结果时,使用以下格式:
|
||||
- 先给出综合评分和风险等级(一句话)
|
||||
- 然后列出关键发现(使用 emoji 标注严重度)
|
||||
- 最后给出建议行动(编号列表)
|
||||
- 始终附上免责声明
|
||||
"""
|
||||
```
|
||||
|
||||
### 7.2 Tool Definition 标注
|
||||
|
||||
```python
|
||||
"""
|
||||
[TOOL_DEFINITION]
|
||||
name: geng_fraud_detection
|
||||
description: |
|
||||
基于统计学原理检测学术论文数据是否存在造假迹象。
|
||||
支持末位数字检测、本福特定律、GRIM测试、固定关系检测、
|
||||
小数位一致性检测和图像重复检测。
|
||||
灵感来源于2026年"耿同学讲故事"的技术流打假方法论。
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
description: 数据行列表,或 CSV 文件路径
|
||||
domain:
|
||||
type: string
|
||||
enum: [biomedical, chemistry, physics, social_science, clinical, general]
|
||||
modules:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [last_digit, benford, grim, fixed_relation, decimal, image]
|
||||
description: 指定运行哪些模块(默认全部)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
overall_risk_score: {type: number, min: 0, max: 100}
|
||||
overall_risk_level: {type: string}
|
||||
findings: {type: array, items: {type: string}}
|
||||
recommendations: {type: array, items: {type: string}}
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 性能与限制
|
||||
|
||||
### 8.1 时间复杂度
|
||||
|
||||
| 模块 | 时间复杂度 | 1000行数据耗时 |
|
||||
|------|-----------|---------------|
|
||||
| last_digit_test | O(n) | <10ms |
|
||||
| benford_test | O(n) | <10ms |
|
||||
| grim_test_batch | O(k) per item | <1ms/item |
|
||||
| fixed_relation_test | O(n) per pair | <10ms |
|
||||
| decimal_consistency_test | O(n) | <20ms |
|
||||
| image_duplicate_test | O(m²) m=图片数 | ~1s/100张 |
|
||||
| geng_assess (综合) | O(n × c²) c=列数 | <500ms |
|
||||
|
||||
### 8.2 已知限制
|
||||
|
||||
| 限制 | 影响 | 缓解方案 |
|
||||
|------|------|----------|
|
||||
| 数据量<30时统计效力低 | 本福特检测可能不准 | 自动标注 "统计效力有限" |
|
||||
| 不支持时间序列自相关 | 遗漏趋势数据伪造 | v1.1 计划增加 |
|
||||
| 固定关系仅检测两列 | 三列以上复杂关系漏检 | 通过两两组合覆盖 |
|
||||
| 图像检测仅用全局特征 | 局部篡改可能漏检 | v1.2 计划增加分块检测 |
|
||||
| 无法检测"高明造假" | 统计上完美的伪造数据 | 无银弹,需多维度交叉 |
|
||||
|
||||
---
|
||||
|
||||
*Geng Skill v1.0.0 — 代码注释与架构标准化文档*
|
||||
294
tools/gengskill/docs/DATA_SOURCES.md
Normal file
294
tools/gengskill/docs/DATA_SOURCES.md
Normal file
@ -0,0 +1,294 @@
|
||||
# 📚 数据来源与参考文献标准化文档
|
||||
|
||||
> Geng Skill v1.0.0 — 学术数据打假检测工具
|
||||
|
||||
---
|
||||
|
||||
## 1. 方法论来源
|
||||
|
||||
### 1.1 直接灵感来源
|
||||
|
||||
| 来源 | 描述 | 时间 |
|
||||
|------|------|------|
|
||||
| **耿同学讲故事** (B站/抖音科普博主) | 吉林大学生物学硕士、北航博士五年级退学。2026年4月起连续举报多所985高校教授论文造假,核心方法:末位数字集中度检测、固定差值/比例关系检测、AI图片查重 | 2026-04 至今 |
|
||||
| **澎湃新闻评论** | 《学术打假需要"耿同学",更需要长效机制建设》— 详述耿同学方法论 | 2026-05-16 |
|
||||
| **虎嗅网** | 《我Skill化了耿同学的"学术打假方法论",致敬》— 方法论结构化梳理 | 2026-05-08 |
|
||||
|
||||
### 1.2 耿同学核心方法总结
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ 耿同学打假方法论(从公开报道中提取) │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ 1. 末位数字集中度 — 某些数字出现频率异常高 │
|
||||
│ 2. 两列数据间固定差值/比例 — 不同组数据存在恒定数学关系 │
|
||||
│ 3. 小数点后位数高度一致 — 编造数据的小数位呈现不自然规律 │
|
||||
│ 4. AI图片查重 — 同一图片在不同实验条件下重复使用 │
|
||||
│ 5. 从PDF/Source Data/图片/表格多维度扒取证据 │
|
||||
│ 6. 卡方检验等统计学方法验证异常的显著性 │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 统计学理论基础
|
||||
|
||||
### 2.1 本福特定律 (Benford's Law)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **原始论文** | Benford, F. (1938). The law of anomalous numbers. *Proceedings of the American Philosophical Society*, 78(4), 551-572. |
|
||||
| **数学表述** | P(d) = log₁₀(1 + 1/d), d ∈ {1,2,...,9} |
|
||||
| **适用条件** | 数据跨越多个数量级(至少1个);数据量≥100为佳 |
|
||||
| **不适用场景** | 范围有限的数据(百分比、pH值);人为截断的数据 |
|
||||
| **权威教材** | Nigrini, M.J. (2012). *Benford's Law: Applications for Forensic Accounting, Auditing, and Fraud Detection*. Wiley. ISBN: 978-1118152850 |
|
||||
| **审计应用** | 美国注册欺诈审查师协会(ACFE)推荐用于财务审计 |
|
||||
| **学术验证** | Diekmann, A. (2007). Not the first digit! Using Benford's law to detect fraudulent scientific data. *Journal of Applied Statistics*, 34(3), 321-329. |
|
||||
|
||||
### 2.2 GRIM 测试 (Granularity-Related Inconsistency of Means)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **原始论文** | Brown, N.J.L., & Heathers, J.A.J. (2017). The GRIM Test: A Simple Technique Detects Numerous Anomalies in the Reporting of Results in Psychology. *Social Psychological and Personality Science*, 8(4), 363-369. DOI: 10.1177/1948550616673876 |
|
||||
| **数学原理** | 对于整数取值数据,样本量为n时,合法均值只能是 k/n 形式(k为整数) |
|
||||
| **适用条件** | 离散整数取值数据(李克特量表、计数数据) |
|
||||
| **扩展** | SPRITE (Sample Parameter Reconstruction via Iterative TEchniques) — 更完整的数据重构验证 |
|
||||
| **参考** | Heathers, J.A.J., et al. (2018). SPRITE: A Response to Anaya's Critique. DOI: 10.31234/osf.io/qfk7d |
|
||||
|
||||
### 2.3 末位数字均匀分布检验
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **理论基础** | 连续测量数据在足够精度下,末位数字应服从离散均匀分布 U(0,9) |
|
||||
| **检验方法** | 皮尔逊卡方检验 (Pearson's chi-squared test), df=9 |
|
||||
| **参考文献** | Mosimann, J.E., et al. (2002). Terminal digits and the examination of questioned data. *Accountability in Research*, 9(2), 75-92. |
|
||||
| **典型案例** | Hill, T.P. (1998). The first digit phenomenon. *American Scientist*, 86, 358-363. |
|
||||
|
||||
### 2.4 图像重复检测
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **里程碑论文** | Bik, E.M., Casadevall, A., & Fang, F.C. (2016). The prevalence of inappropriate image duplication in biomedical research publications. *mBio*, 7(3), e00809-16. DOI: 10.1128/mBio.00809-16 |
|
||||
| **发现** | 分析20,621篇论文,3.8%存在图片问题 |
|
||||
| **技术方法** | 感知哈希(pHash)、差异哈希(dHash)、结构相似性(SSIM) |
|
||||
| **工具参考** | ImageTwin, Proofig, STM Integrity Hub |
|
||||
|
||||
### 2.5 数据一致性综合检验
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **中文权威** | 余菁, 邬加佳, 孙慧兰等 (2021). 科技论文数据造假的核查策略和统计学方法验证. *中国科技期刊研究*, 32(6), 770-776. DOI: 10.11946/cjstp.202012221043 |
|
||||
| **方法体系** | t检验、F检验、卡方检验、生存分析一致性 |
|
||||
| **国际标准** | COPE (Committee on Publication Ethics) Guidelines on Research Data |
|
||||
|
||||
---
|
||||
|
||||
## 3. 检测模块与理论对应关系
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 模块名称 │ 理论基础 │ 统计方法 │ 适用领域 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Last Digit Test │ 末位均匀分布 │ χ² 检验 │ 全领域 │
|
||||
│ Benford's Law Test │ 本福特定律 │ χ² + MAD │ 跨数量级 │
|
||||
│ GRIM Test │ 离散粒度一致性 │ 整除验证 │ 社科/量表 │
|
||||
│ Fixed Relation Test │ 独立性原理 │ 比值/回归 │ 全领域(核心) │
|
||||
│ Decimal Consistency │ 随机性原理 │ 自相关+χ² │ 全领域 │
|
||||
│ Image Duplication │ 唯一性原理 │ 哈希+SSIM │ 生物医学 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 已验证的真实案例
|
||||
|
||||
### 4.1 耿同学举报案例(2026年,已被机构确认)
|
||||
|
||||
| 案例 | 机构 | 期刊 | 问题类型 | 结果 |
|
||||
|------|------|------|----------|------|
|
||||
| 王平团队 | 同济大学生科院 | *Nature* | 系统性数据造假(固定数学关系、图片重复) | ✅ 确认,院长免职,第一作者解聘 |
|
||||
| 陈佺团队 | 南开大学生科院 | *Nature* 子刊 | 数据异常 | 🔄 调查中 |
|
||||
| 上海大学案例 | 上海大学 | — | 数据异常 | 🔄 调查中 |
|
||||
| 中山大学案例 | 中山大学 | — | 数据异常 | 🔄 调查中 |
|
||||
|
||||
### 4.2 国际经典案例
|
||||
|
||||
| 案例 | 方法 | 年份 |
|
||||
|------|------|------|
|
||||
| Diederik Stapel (社会心理学) | GRIM + 统计不一致性 | 2011 |
|
||||
| Paolo Macchiarini (再生医学) | 图像重复 + 数据伪造 | 2016 |
|
||||
| Hwang Woo-suk (干细胞) | 图像篡改检测 | 2005 |
|
||||
| Jan Hendrik Schön (物理) | 数据重复模式 | 2002 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据标准与输入规范
|
||||
|
||||
### 5.1 CSV 输入格式标准
|
||||
|
||||
```
|
||||
编码: UTF-8 (支持 UTF-8-BOM)
|
||||
分隔符: 逗号 (默认), 可配置为 TAB/分号
|
||||
表头: 必须有列名作为第一行
|
||||
数值: 支持整数、小数、科学计数法
|
||||
缺失值: 空字符串 (跳过处理)
|
||||
```
|
||||
|
||||
**标准示例:**
|
||||
```csv
|
||||
sample_id,group,value,measurement,timepoint
|
||||
1,control,2.34,12.5,0
|
||||
2,control,3.12,15.8,0
|
||||
3,treatment,4.68,25.0,24
|
||||
```
|
||||
|
||||
### 5.2 GRIM 批量输入 JSON 格式
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"label": "Table 1, Row 1",
|
||||
"mean": "3.47",
|
||||
"n": 25,
|
||||
"decimals": 2,
|
||||
"scale_min": 1,
|
||||
"scale_max": 5
|
||||
},
|
||||
{
|
||||
"label": "Table 1, Row 2",
|
||||
"mean": "4.12",
|
||||
"n": 30,
|
||||
"decimals": 2,
|
||||
"scale_min": 1,
|
||||
"scale_max": 5
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 5.3 图像输入规范
|
||||
|
||||
```
|
||||
支持格式: PNG, JPG, JPEG, TIF, TIFF, BMP, GIF
|
||||
最小尺寸: 32×32 像素
|
||||
推荐: 原始分辨率(不要人为缩放)
|
||||
组织方式: 所有待比较图片放在同一目录下
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 输出标准化
|
||||
|
||||
### 6.1 JSON 输出 Schema
|
||||
|
||||
所有模块遵循统一输出结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "geng-skill-output-v1",
|
||||
"test_name": "string — 模块名称(中英双语)",
|
||||
"status": "enum: completed | insufficient_data | error",
|
||||
"n_values": "integer — 有效数据点数",
|
||||
"risk_level": "enum: low | medium | medium-high | high",
|
||||
"risk_score": "number 0-100 — 风险评分",
|
||||
"p_value": "number — 统计检验p值(如适用)",
|
||||
"interpretation": "string — 中文可读解释(含emoji状态标识)",
|
||||
"...": "模块特定字段"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 风险评分映射标准
|
||||
|
||||
| p-value 范围 | 风险等级 | 评分范围 | 颜色代码 | 建议动作 |
|
||||
|-------------|----------|----------|----------|----------|
|
||||
| p > 0.05 | low | 0-25 | 🟢 #00C853 | 无需干预 |
|
||||
| 0.01 < p ≤ 0.05 | medium | 26-50 | 🟡 #FFD600 | 人工复核 |
|
||||
| 0.001 < p ≤ 0.01 | medium-high | 51-75 | 🟠 #FF6D00 | 深入调查 |
|
||||
| p ≤ 0.001 | high | 76-100 | 🔴 #D50000 | 正式举报 |
|
||||
|
||||
### 6.3 Markdown 报告标准
|
||||
|
||||
综合报告遵循以下结构:
|
||||
|
||||
```markdown
|
||||
# 📋 Geng 学术数据打假检测报告
|
||||
## 📊 综合评估结果(表格)
|
||||
## 📝 结论(一段话)
|
||||
## 💡 建议(编号列表)
|
||||
## 🔬 各模块检测详情
|
||||
### Module N: 模块名称
|
||||
## ⚠️ 重要声明
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 学术伦理与法律合规
|
||||
|
||||
### 7.1 合规框架
|
||||
|
||||
| 标准/规范 | 发布机构 | 相关性 |
|
||||
|-----------|----------|--------|
|
||||
| COPE Retraction Guidelines | 出版伦理委员会 | 论文撤稿/更正流程 |
|
||||
| 科研诚信案件调查处理规则 | 中国科技部 (2019) | 国内学术不端处理 |
|
||||
| ORI Research Integrity Guidelines | 美国研究诚信办公室 | 国际标准 |
|
||||
| Singapore Statement | 全球科研诚信大会 | 负责任研究行为 |
|
||||
|
||||
### 7.2 使用伦理准则
|
||||
|
||||
1. **比例原则** — 检测强度应与嫌疑程度成正比
|
||||
2. **无罪推定** — 异常 ≠ 造假,需完整证据链
|
||||
3. **保密义务** — 未经确认的检测结果不应公开传播
|
||||
4. **正式渠道** — 确认后应通过机构/期刊正式途径举报
|
||||
5. **避免伤害** — 不应基于工具结果对个人进行网络攻击
|
||||
|
||||
### 7.3 免责声明
|
||||
|
||||
```
|
||||
本工具仅提供统计学层面的异常筛查功能,输出结果为"疑点线索"而非
|
||||
"造假定论"。使用者应当理解:
|
||||
- 统计异常可能有合理解释(仪器精度、数据处理等)
|
||||
- 本工具不具备法律效力
|
||||
- 最终判定需要领域专家、原始数据核查和正式调查程序
|
||||
- 使用者需自行承担因不当使用造成的后果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本与更新日志
|
||||
|
||||
### v1.0.0 (2026-05-20)
|
||||
|
||||
- 初始发布
|
||||
- 6个核心检测模块
|
||||
- 综合评估引擎
|
||||
- 多平台使用指南
|
||||
- 标准化输出格式
|
||||
|
||||
### 路线图
|
||||
|
||||
| 版本 | 计划功能 |
|
||||
|------|----------|
|
||||
| v1.1 | 增加 SPRITE 测试、生存数据一致性检验 |
|
||||
| v1.2 | 支持 Excel 直接输入、PDF 表格自动提取 |
|
||||
| v1.3 | Web UI 界面、RESTful API |
|
||||
| v2.0 | AI 增强检测(LLM 辅助判断上下文合理性) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 引用本工具
|
||||
|
||||
如果在学术工作中使用了本工具,请引用:
|
||||
|
||||
```bibtex
|
||||
@software{geng_skill_2026,
|
||||
title = {Geng Skill: Academic Data Fraud Detection Toolkit},
|
||||
author = {Contributors},
|
||||
year = {2026},
|
||||
url = {https://github.com/YOUR_USERNAME/geng-skill},
|
||||
version = {1.0.0},
|
||||
note = {Inspired by the methodology of "Geng Tongxue" (耿同学讲故事)}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Geng Skill — 让学术回归诚信,让数据说出真相。*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user