diff --git a/go.mod b/go.mod index a3e19ce..5d7c2eb 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,11 @@ module gitcode.com/JianFeeeee/HomeAgent go 1.19 require ( + gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260706112739-e073074a267d github.com/gorilla/websocket v1.5.3 github.com/mattn/go-sqlite3 v1.14.22 github.com/yanyiwu/gojieba v1.4.7 github.com/yuin/gopher-lua v1.1.2 - gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 gopkg.in/yaml.v3 v3.0.1 ) -replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk diff --git a/go.sum b/go.sum index d1b2f0d..dc12630 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260706112739-e073074a267d h1:rLy0WQ9a2lDwSfWyPhfIEN0IbOPwso8SFw3TQ913+Zk= +gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260706112739-e073074a267d/go.mod h1:mzs91WBioKDpiMLXZ7t/LXTmAh3DRu3RnDDoSztTcLg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= diff --git a/internal/memory/pipeline/pipeline.go b/internal/memory/pipeline/pipeline.go index 26a4815..bb5602c 100644 --- a/internal/memory/pipeline/pipeline.go +++ b/internal/memory/pipeline/pipeline.go @@ -1,11 +1,14 @@ package pipeline import ( + "bufio" "context" "fmt" "log" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "time" @@ -103,24 +106,70 @@ func (d *Distiller) loadExisting() { if err != nil { return } + cutoff := time.Now().AddDate(0, 0, -d.cfg.RetentionDays) + type fileInfo struct { + name string + mod time.Time + } + var files []fileInfo for _, entry := range entries { if filepath.Ext(entry.Name()) != ".jsonl" { continue } - path := filepath.Join(d.rawPath, entry.Name()) - data, err := os.ReadFile(path) + info, err := entry.Info() if err != nil { continue } - for _, line := range parseLines(string(data)) { - parts := splitLine(line) - if len(parts) >= 4 { + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(d.rawPath, entry.Name())) + continue + } + files = append(files, fileInfo{name: entry.Name(), mod: info.ModTime()}) + } + sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) }) + loaded := 0 + const maxStartupRecords = 5000 + for _, entry := range files { + if loaded >= maxStartupRecords { + break + } + path := filepath.Join(d.rawPath, entry.name) + f, err := os.Open(path) + if err != nil { + continue + } + func() { + defer f.Close() + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + if loaded >= maxStartupRecords { + break + } + line := scanner.Text() + parts := splitLine(line) + if len(parts) < 5 { + continue + } + ts, err := strconv.ParseInt(parts[4], 10, 64) + if err != nil { + continue + } + createdAt := time.Unix(ts, 0) + if createdAt.Before(cutoff) { + continue + } d.records = append(d.records, RawRecord{ - ID: d.nextID, SessionID: parts[1], Role: parts[2], Content: parts[3], + ID: d.nextID, SessionID: parts[1], Role: parts[2], Content: parts[3], CreatedAt: createdAt, }) d.nextID++ + loaded++ } - } + }() + } + if loaded >= maxStartupRecords { + log.Printf("[memory] distiller startup load capped at %d recent records", loaded) } } @@ -395,39 +444,17 @@ func truncate(s string, max int) string { } func parseLines(data string) []string { - var lines []string - current := "" - for _, ch := range data { - if ch == '\n' { - if current != "" { - lines = append(lines, current) - } - current = "" - } else { - current += string(ch) - } + if data == "" { + return nil } - if current != "" { - lines = append(lines, current) - } - return lines + return strings.Split(strings.TrimRight(data, "\n"), "\n") } func splitLine(line string) []string { - var parts []string - current := "" - for _, ch := range line { - if ch == '\t' { - parts = append(parts, current) - current = "" - } else { - current += string(ch) - } + if line == "" { + return nil } - if current != "" { - parts = append(parts, current) - } - return parts + return strings.SplitN(line, "\t", 5) } func (d *Distiller) GetRecentRecords(limit int) []RawRecord { diff --git a/third_party/homeagent-sdk/.gitignore b/third_party/homeagent-sdk/.gitignore deleted file mode 100644 index 0d63db8..0000000 --- a/third_party/homeagent-sdk/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.so -*.hmap diff --git a/third_party/homeagent-sdk/README.md b/third_party/homeagent-sdk/README.md deleted file mode 100644 index 97b0651..0000000 --- a/third_party/homeagent-sdk/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# HomeAgent Plugin SDK - -HomeAgent 外部插件开发工具包。用于开发独立于内核的 `.so` 动态插件。 - -## 目录结构 - -``` -homeagent-sdk/ -├── sdk/ # Go SDK 包(import: gitcode.com/JianFeeeee/homeagent-sdk/sdk) -│ ├── plugin.go # Plugin 接口、PluginSDK、ToolDef、ToolHandler -│ ├── settings.go # SettingsAPI(插件配置读写) -│ ├── memory.go # MemoryAPI / TextMemoryAPI / DocMemoryAPI -│ ├── knowledge.go # KnowledgeAPI(知识库访问) -│ ├── llm.go # LLMAPI(LLM 源管理) -│ └── API.md # 完整 API 参考文档 -├── hack/plugin-dev/ # 开发工具 -│ ├── scaffold.sh # 脚手架:生成新插件项目 -│ ├── packager.sh # 打包插件为 .hmap 分发包 -│ └── testharness/ # 插件测试框架 -├── example/ # 完整插件示例 -│ ├── qq/ # QQ 集成(对接 NapCat OneBot) -│ ├── files/ # 文件系统操作 -│ ├── memo/ # 备忘提醒 -│ └── web/ # 网络搜索与抓取 -└── README.md -``` - -## 快速开始 - -### 前置条件 - -- Go 1.21+ -- 运行中的 HomeAgent 内核(用于部署插件) - -### 创建插件 - -```bash -git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git -cd homeagent-sdk - -# 用脚手架生成项目骨架 -hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin - -# 编辑插件代码 -vim plugins/myplugin/plugin.go -``` - -### 插件接口 - -每个插件必须实现三个方法: - -```go -type Plugin interface { - Name() string // 插件名称 - Start(sdk *PluginSDK) error // 启动:注册工具、阶段钩子等 - Stop() error // 停止:清理资源 -} -``` - -入口函数签名(插件 .so 必须导出此函数): - -```go -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) -``` - -### 编译 - -```bash -# 从插件目录 -cd plugins/myplugin && make - -# 或手动编译 -cd && go build -buildmode=plugin -o /plugin.so -``` - -### 部署 - -将插件目录放入 HomeAgent 内核的插件目录(`/plugins//`): - -``` -/plugins/myplugin/ - plugin.json — {"name": "myplugin", "version": "1.0", "entry": "plugin.so"} - plugin.so — 编译产物 -``` - -内核启动时自动发现并加载。也可通过 WebUI 插件管理页面上传 `.hmap` 包安装。 - -## PluginSDK API 参考 - -完整 API 文档见 [sdk/API.md](sdk/API.md),涵盖: - -- **工具注册** — `RegisterTool`、`ToolDef`、`ToolHandler` -- **阶段钩子** — 7 个阶段的 `StageContext` 读写权限、工具归属插件字段和短路规则 -- **输入投递** — `InjectText` / `InjectInterruptText` 两种投递方式 -- **配置管理** — `SettingsAPI`,含自身/核心/跨插件配置 -- **记忆访问** — 图记忆(`MemoryAPI`)、文档记忆(`DocMemoryAPI`)、文本记忆(`TextMemoryAPI`) -- **知识库** — `KnowledgeAPI` 搜索/添加/列表 -- **LLM 管理** — `LLMAPI` 源切换 -- **所有 SDK 类型定义** — `ToolCall`、`StageContext`、`Entity`、`Triple`、`ConfigDef` 等 - -## 打包分发 - -```bash -hack/plugin-dev/packager.sh plugins/myplugin -# 输出: dist/myplugin-0.1.0.hmap -``` - -`.hmap` 文件是一个 zip 包,内含: -- `plugin.json` — 清单文件(名称、版本、入口) -- `plugin.so` — 编译好的 Go 插件 - -通过 WebUI 插件管理器上传安装。 - -## 测试 - -SDK 提供测试框架 `testharness`,可加载 .so 并模拟调用: - -```go -import "gitcode.com/JianFeeeee/homeagent-sdk/hack/plugin-dev/testharness" - -func TestMyPlugin(t *testing.T) { - h := testharness.New(t, "./plugin.so") - defer h.Close() - - result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{ - "input": "hello", - }) - // ... -} -``` - -## 示例插件 - -每个示例目录下都有对应的 `README.md`,包含详细的设计讲解和源码引用。 - -| 示例 | 说明 | 详细文档 | -|------|------|----------| -| [QQ](example/qq/) | 对接 NapCat OneBot,15 个工具,涵盖消息/群/好友/文件/OCR | [讲解](example/qq/README.md) | -| [Files](example/files/) | 文件系统操作,4 种写入模式,分段读取,沙箱隔离 | [讲解](example/files/README.md) | -| [Memo](example/memo/) | 备忘管理,PreAction 注入 + 定时打断双提醒 | [讲解](example/memo/README.md) | -| [Web](example/web/) | DuckDuckGo 搜索 + 网页抓取,SSRF 防护,代理支持 | [讲解](example/web/README.md) | - -## License - -MIT \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/files/README.md b/third_party/homeagent-sdk/example/files/README.md deleted file mode 100644 index 8c9cac6..0000000 --- a/third_party/homeagent-sdk/example/files/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# files 插件讲解 - -文件系统操作插件,提供文件的读写编辑和目录浏览能力。 - -## 工具清单 - -| 工具 | 功能 | 源码 | -|------|------|------| -| `files_read` | 读取文件内容,支持 offset/limit 分段 | `handleRead` | -| `files_write` | 写入文件,支持 4 种模式 | `handleWrite` | -| `files_edit` | 精确字符串替换编辑 | `handleEdit` | -| `files_ls` | 列出目录内容 | `handleLs` | - -## 核心设计 - -### 沙箱路径隔离 - -`resolvePath()` 方法将用户传入的路径解析为沙箱内的绝对路径。关键逻辑: - -```go -// 相对路径以沙箱根目录为基准拼接 -if !filepath.IsAbs(userPath) { - userPath = filepath.Join(p.filesDir, userPath) -} -// 检查是否越界 -base := filepath.Clean(p.filesDir) -if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base { - return "", fmt.Errorf("path outside sandbox") -} -``` - -当沙箱根设为 `/` 时放行所有路径;设为特定目录时拒绝访问外部。配置项 `plugin.files.dir` 控制此值。 - -### 分段读取 - -`files_read` 支持 `offset`(行号,1-indexed)和 `limit`(行数上限),用于大文件分段查看: - -```go -// plugin.go:handleRead -lines := strings.Split(text, "\n") -offset := 0 // 从 args["offset"] 解析,1-indexed 转 0-indexed -limit := totalLines - offset -// ... -end := offset + limit -selected := lines[offset:end] -``` - -如果未读完会在末尾追加提示 `[Showing lines X-Y of Z. Use offset=N to continue.]`。 - -### 四种写入模式 - -`files_write` 通过 `mode` 参数区分: - -- **overwrite**(默认):`os.WriteFile` 覆盖写入,自动创建父目录 -- **append**:`os.OpenFile` 以 `O_APPEND|O_CREATE|O_WRONLY` 打开,追加内容 -- **insert**:将文件按行分割,在指定行号前插入新内容,再写回 -- **create**:先检查文件是否已存在,存在则报错,不存在才创建 - -### 精确编辑 - -`files_edit` 接收 `edits` 数组,每个元素有 `old` 和 `new`。要求每个 `old` 在原文中**恰好出现一次**,防止 LLM 误替换: - -```go -count := strings.Count(content, oldText) -if count == 0 { /* 报错未找到 */ } -if count > 1 { /* 报错存在多处匹配 */ } -content = strings.Replace(content, oldText, newText, 1) -``` - -### 目录列表 - -`files_ls` 按字母序排序,目录加 `/` 后缀,同时显示文件大小。默认上限 500 条。 - -## 配置项 - -| Key | 默认值 | 说明 | -|-----|--------|------| -| `plugin.files.dir` | `/` | 文件操作沙箱根目录 | - -## 注意事项 - -- 所有路径操作前都经过 `resolvePath` 沙箱检查 -- 错误结果统一用 `errorResult()` 返回 `{isError: true, content: msg}` 格式,LLM 可据此判断 -- `files_write` 的 insert/append 模式不检查文件是否存在(不存在则报错),overwrite/create 模式自动创建父目录 diff --git a/third_party/homeagent-sdk/example/files/plugin.go b/third_party/homeagent-sdk/example/files/plugin.go deleted file mode 100644 index 53a48d2..0000000 --- a/third_party/homeagent-sdk/example/files/plugin.go +++ /dev/null @@ -1,482 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "path/filepath" - "sort" - "strings" - "sync" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - filesDir string -} - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "plugin.files.dir", - Default: "/", - Type: "string", - DisplayName: "文件系统根目录", - Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)", - Category: "files", - }) - - dir := getSetting[string](s.Settings(), "dir", "/") - if strings.HasPrefix(dir, "~/") { - home, _ := os.UserHomeDir() - dir = filepath.Join(home, dir[2:]) - } - abs, err := filepath.Abs(dir) - if err != nil { - return fmt.Errorf("resolve files.dir: %w", err) - } - p.filesDir = abs - os.MkdirAll(p.filesDir, 0755) - - tp := p.name + "_" - - s.RegisterTool(tp+"read", sdk.ToolDef{ - Name: tp + "read", - Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir), - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"}, - "offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"}, - "limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"}, - }, - "required": []string{"path"}, - }, - }, p.handleRead) - - s.RegisterTool(tp+"write", sdk.ToolDef{ - Name: tp + "write", - Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir), - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path"}, - "content": map[string]interface{}{"type": "string", "description": "Content to write"}, - "mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"}, - "line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"}, - }, - "required": []string{"path", "content"}, - }, - }, p.handleWrite) - - s.RegisterTool(tp+"edit", sdk.ToolDef{ - Name: tp + "edit", - Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir), - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"}, - "edits": map[string]interface{}{ - "type": "array", - "description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"}, - "new": map[string]interface{}{"type": "string", "description": "Replacement text"}, - }, - "required": []string{"old", "new"}, - }, - }, - }, - "required": []string{"path", "edits"}, - }, - }, p.handleEdit) - - s.RegisterTool(tp+"ls", sdk.ToolDef{ - Name: tp + "ls", - Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir), - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"}, - "limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"}, - }, - }, - }, p.handleLs) - - log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir) - return nil -} - -func (p *Plugin) Stop() error { - log.Printf("[%s] stopped", p.name) - return nil -} - -// resolvePath resolves user-provided path to an absolute path within filesDir. -func (p *Plugin) resolvePath(userPath string) (string, error) { - if userPath == "" { - userPath = "." - } - if !filepath.IsAbs(userPath) { - userPath = filepath.Join(p.filesDir, userPath) - } - abs, err := filepath.Abs(userPath) - if err != nil { - return "", fmt.Errorf("resolve path: %w", err) - } - base := filepath.Clean(p.filesDir) - if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base { - return "", fmt.Errorf("path outside sandbox: %s", userPath) - } - return abs, nil -} - -// handleRead implements the read tool. -func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { - path, _ := args["path"].(string) - if path == "" { - return errorResult("path is required"), nil - } - - absPath, err := p.resolvePath(path) - if err != nil { - return errorResult(err.Error()), nil - } - - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { - return errorResult("file not found: " + path), nil - } - return errorResult("stat error: " + err.Error()), nil - } - if info.IsDir() { - return errorResult("is a directory, use ls instead: " + path), nil - } - - data, err := os.ReadFile(absPath) - if err != nil { - return errorResult("read error: " + err.Error()), nil - } - - text := string(data) - lines := strings.Split(text, "\n") - totalLines := len(lines) - - offset := 0 - if v, ok := args["offset"].(float64); ok && v > 0 { - offset = int(v) - 1 - } - if offset >= totalLines { - return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil - } - - limit := totalLines - offset - if v, ok := args["limit"].(float64); ok && v > 0 { - if int(v) < limit { - limit = int(v) - } - } - - end := offset + limit - if end > totalLines { - end = totalLines - } - - selected := lines[offset:end] - output := strings.Join(selected, "\n") - - truncated := false - if limit < totalLines-offset { - truncated = true - } - - var sb strings.Builder - sb.WriteString(output) - if truncated { - nextOffset := end + 1 - sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset)) - } else if offset > 0 || end < totalLines { - sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines)) - } - - return map[string]interface{}{ - "content": sb.String(), - }, nil -} - -// handleWrite implements the write tool. -func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) { - path, _ := args["path"].(string) - if path == "" { - return errorResult("path is required"), nil - } - content, _ := args["content"].(string) - mode, _ := args["mode"].(string) - if mode == "" { - mode = "overwrite" - } - - line := 0 - if v, ok := args["line"].(float64); ok && v > 0 { - line = int(v) - } - - absPath, err := p.resolvePath(path) - if err != nil { - return errorResult(err.Error()), nil - } - - switch mode { - case "create": - if _, err := os.Stat(absPath); err == nil { - return errorResult("file already exists: " + path), nil - } - dir := filepath.Dir(absPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return errorResult("mkdir error: " + err.Error()), nil - } - if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { - return errorResult("write error: " + err.Error()), nil - } - return map[string]interface{}{ - "content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)), - }, nil - - case "append": - dir := filepath.Dir(absPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return errorResult("mkdir error: " + err.Error()), nil - } - f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return errorResult("open error: " + err.Error()), nil - } - defer f.Close() - if _, err := f.WriteString(content); err != nil { - return errorResult("append error: " + err.Error()), nil - } - return map[string]interface{}{ - "content": fmt.Sprintf("Appended %d bytes to %s", len(content), path), - }, nil - - case "insert": - if line < 1 { - return errorResult("line must be >= 1 for insert mode"), nil - } - data, err := os.ReadFile(absPath) - if err != nil { - if os.IsNotExist(err) { - return errorResult("file not found: " + path), nil - } - return errorResult("read error: " + err.Error()), nil - } - lines := strings.Split(string(data), "\n") - if line > len(lines)+1 { - return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil - } - idx := line - 1 - newLines := make([]string, 0, len(lines)+1) - newLines = append(newLines, lines[:idx]...) - newLines = append(newLines, content) - newLines = append(newLines, lines[idx:]...) - result := strings.Join(newLines, "\n") - if err := os.WriteFile(absPath, []byte(result), 0644); err != nil { - return errorResult("write error: " + err.Error()), nil - } - return map[string]interface{}{ - "content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path), - }, nil - - default: // overwrite - dir := filepath.Dir(absPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return errorResult("mkdir error: " + err.Error()), nil - } - if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { - return errorResult("write error: " + err.Error()), nil - } - return map[string]interface{}{ - "content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path), - }, nil - } -} - -// handleEdit implements the edit tool. -func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) { - path, _ := args["path"].(string) - if path == "" { - return errorResult("path is required"), nil - } - - absPath, err := p.resolvePath(path) - if err != nil { - return errorResult(err.Error()), nil - } - - rawEdits, ok := args["edits"].([]interface{}) - if !ok || len(rawEdits) == 0 { - return errorResult("edits must be a non-empty array"), nil - } - - data, err := os.ReadFile(absPath) - if err != nil { - if os.IsNotExist(err) { - return errorResult("file not found: " + path), nil - } - return errorResult("read error: " + err.Error()), nil - } - - original := string(data) - content := original - applied := 0 - var errors []string - - for i, raw := range rawEdits { - edit, ok := raw.(map[string]interface{}) - if !ok { - errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i)) - continue - } - oldText, _ := edit["old"].(string) - newText, _ := edit["new"].(string) - if oldText == "" { - errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i)) - continue - } - - count := strings.Count(content, oldText) - if count == 0 { - errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path)) - continue - } - if count > 1 { - errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText)) - continue - } - - content = strings.Replace(content, oldText, newText, 1) - applied++ - } - - if applied == 0 { - msg := "no edits applied" - if len(errors) > 0 { - msg += ": " + strings.Join(errors, "; ") - } - return errorResult(msg), nil - } - - if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { - return errorResult("write error: " + err.Error()), nil - } - - msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path) - if len(errors) > 0 { - msg += "\nWarnings:\n" + strings.Join(errors, "\n") - } - - return map[string]interface{}{ - "content": msg, - }, nil -} - -// handleLs implements the ls tool. -func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) { - path, _ := args["path"].(string) - if path == "" { - path = "." - } - - absPath, err := p.resolvePath(path) - if err != nil { - return errorResult(err.Error()), nil - } - - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { - return errorResult("path not found: " + path), nil - } - return errorResult("stat error: " + err.Error()), nil - } - if !info.IsDir() { - return errorResult("not a directory: " + path), nil - } - - entries, err := os.ReadDir(absPath) - if err != nil { - return errorResult("readdir error: " + err.Error()), nil - } - - limit := 500 - if v, ok := args["limit"].(float64); ok && v > 0 { - limit = int(v) - } - - sort.Slice(entries, func(i, j int) bool { - return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) - }) - - var lines []string - entryLimitReached := false - for i, entry := range entries { - if i >= limit { - entryLimitReached = true - break - } - name := entry.Name() - if entry.IsDir() { - name += "/" - } - lines = append(lines, name) - } - - if len(lines) == 0 { - return map[string]interface{}{ - "content": "(empty directory)", - }, nil - } - - output := strings.Join(lines, "\n") - if entryLimitReached { - output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit) - } - - return map[string]interface{}{ - "content": output, - }, nil -} - -// errorResult returns a standardized error result. -func errorResult(msg string) map[string]interface{} { - return map[string]interface{}{ - "isError": true, - "content": msg, - } -} - -// getSetting reads a setting with generic type assertion. -func getSetting[T any](s sdk.SettingsAPI, key string, def T) T { - v, err := s.Get(key) - if err != nil || v == nil { - return def - } - val, ok := v.(T) - if !ok { - return def - } - return val -} diff --git a/third_party/homeagent-sdk/example/files/plugin.json b/third_party/homeagent-sdk/example/files/plugin.json deleted file mode 100644 index ddefb89..0000000 --- a/third_party/homeagent-sdk/example/files/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "files", - "version": "1.0.0", - "description": "文件系统操作插件,提供文件读写、编辑、目录列表等工具", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["files", "filesystem", "io"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/memo/README.md b/third_party/homeagent-sdk/example/memo/README.md deleted file mode 100644 index 31f142e..0000000 --- a/third_party/homeagent-sdk/example/memo/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# memo 插件讲解 - -备忘插件,支持创建、完成、列出备忘,自动提醒未完成事项。 - -## 工具清单 - -| 工具 | 功能 | 源码 | -|------|------|------| -| `memo_create` | 创建一条备忘 | `handleCreate` | -| `memo_complete` | 标记备忘为已完成 | `handleComplete` | -| `memo_list` | 列出所有未完成备忘 | `handleList` | - -## 核心设计 - -### 数据持久化 - -备忘存储在 JSON 文件中,路径由核心配置 `core.daemon.data_dir` 决定: - -```go -// plugin.go:Start -dataDirVal, _ := s.Settings().GetCore("core.daemon.data_dir") -p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json") -p.load() -``` - -`load()` 和 `save()` 实现 JSON 文件的读写,格式为 `{memos: [...], next_id: N}`。每次写操作后自动 `save()`,Stop 时也执行一次。 - -### PreAction 注入提醒 - -注册 `pre_action` 阶段钩子,在每次 LLM 调用前注入未完成备忘数量: - -```go -// plugin.go:stagePreAction -n := p.pendingCount() -if n == 0 { return nil } -ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ - "role": "system", - "content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp), -}) -``` - -这样每次 LLM 处理消息时都感知到未完成备忘,无需主动查询。 - -### 定时打断提醒 - -每 5 分钟检查未完成备忘,如果有则通过中断通道提醒: - -```go -// plugin.go:periodicCheck -ticker := time.NewTicker(5 * time.Minute) -for { - select { - case <-p.stopCh: return - case <-ticker.C: - n := p.pendingCount() - if n == 0 { continue } - p.sdk.InjectInterruptText(p.name, p.name, - fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n)) - } -} -``` - -中断消息会打断当前 LLM 处理,在下一轮工具循环前插入 `[打断消息]`,确保 agent 不会长期忽略未完成备忘。 - -### 工具返回值 - -所有工具返回 `{content: string}` 或 `{isError: true, content: string}` 格式,LLM 通过 content 字段获取结果文本。 - -## 注意事项 - -- `memo_create` 的 content 参数应包含事项的完整描述,方便后续回顾 -- `memo_complete` 只标记为 done,不删除数据,保留历史 -- 更早的暂停时自动 save,防丢数据 diff --git a/third_party/homeagent-sdk/example/memo/plugin.go b/third_party/homeagent-sdk/example/memo/plugin.go deleted file mode 100644 index 07813a7..0000000 --- a/third_party/homeagent-sdk/example/memo/plugin.go +++ /dev/null @@ -1,273 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Memo struct { - ID int64 `json:"id"` - Content string `json:"content"` - CreatedAt int64 `json:"created_at"` - Done bool `json:"done"` -} - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - memos []Memo - nextID int64 - filePath string - stopCh chan struct{} - tp string -} - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - p.tp = p.name + "_" - p.stopCh = make(chan struct{}) - - dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") - if err != nil || dataDirVal == "" { - dataDirVal = "." - } - p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json") - p.load() - - s.RegisterTool(p.tp+"create", sdk.ToolDef{ - Name: p.tp + "create", - Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "content": map[string]interface{}{"type": "string", "description": "备忘内容"}, - }, - "required": []string{"content"}, - }, - }, p.handleCreate) - - s.RegisterTool(p.tp+"complete", sdk.ToolDef{ - Name: p.tp + "complete", - Description: "将指定ID的备忘标记为已完成。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "integer", "description": "备忘ID"}, - }, - "required": []string{"id"}, - }, - }, p.handleComplete) - - s.RegisterTool(p.tp+"list", sdk.ToolDef{ - Name: p.tp + "list", - Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleList) - - s.RegisterStage(sdk.StagePreAction, p.stagePreAction) - - go p.periodicCheck() - - log.Printf("[%s] started, path=%s", p.name, p.filePath) - return nil -} - -func (p *Plugin) Stop() error { - close(p.stopCh) - p.save() - log.Printf("[%s] stopped", p.name) - return nil -} - -func (p *Plugin) load() { - p.mu.Lock() - defer p.mu.Unlock() - data, err := os.ReadFile(p.filePath) - if err != nil { - p.memos = nil - p.nextID = 1 - return - } - var store struct { - Memos []Memo `json:"memos"` - NextID int64 `json:"next_id"` - } - if json.Unmarshal(data, &store) != nil { - p.memos = nil - p.nextID = 1 - return - } - p.memos = store.Memos - p.nextID = store.NextID - if p.memos == nil { - p.memos = []Memo{} - } - if p.nextID < 1 { - p.nextID = 1 - } -} - -func (p *Plugin) save() { - data, _ := json.MarshalIndent(map[string]interface{}{ - "memos": p.memos, - "next_id": p.nextID, - }, "", " ") - os.WriteFile(p.filePath, data, 0644) -} - -func (p *Plugin) pendingCount() int { - p.mu.RLock() - defer p.mu.RUnlock() - n := 0 - for _, m := range p.memos { - if !m.Done { - n++ - } - } - return n -} - -func (p *Plugin) pendingMemos() []Memo { - p.mu.RLock() - defer p.mu.RUnlock() - var out []Memo - for _, m := range p.memos { - if !m.Done { - out = append(out, m) - } - } - return out -} - -func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error { - n := p.pendingCount() - if n == 0 { - return nil - } - ctx.Lock() - ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ - "role": "system", - "content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp), - }) - ctx.Unlock() - return nil -} - -func (p *Plugin) periodicCheck() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-p.stopCh: - return - case <-ticker.C: - n := p.pendingCount() - if n == 0 { - continue - } - if p.sdk != nil { - p.sdk.InjectInterruptText(p.name, p.name, - fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n)) - } - } - } -} - -func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) { - content, _ := args["content"].(string) - if content == "" { - return errorResult("content is required"), nil - } - - p.mu.Lock() - memo := Memo{ - ID: p.nextID, - Content: content, - CreatedAt: time.Now().Unix(), - Done: false, - } - p.nextID++ - p.memos = append(p.memos, memo) - p.mu.Unlock() - p.save() - - return map[string]interface{}{ - "content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID), - "id": memo.ID, - }, nil -} - -func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) { - id, ok := args["id"].(float64) - if !ok { - return errorResult("id is required"), nil - } - - p.mu.Lock() - found := false - for i := range p.memos { - if p.memos[i].ID == int64(id) && !p.memos[i].Done { - p.memos[i].Done = true - found = true - break - } - } - p.mu.Unlock() - - if !found { - return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil - } - p.save() - - return map[string]interface{}{ - "content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)), - }, nil -} - -func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) { - memos := p.pendingMemos() - if len(memos) == 0 { - return map[string]interface{}{ - "content": "暂无未完成的备忘", - }, nil - } - - var sb strings.Builder - for i, m := range memos { - t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04") - if i > 0 { - sb.WriteString("\n") - } - sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t)) - } - - return map[string]interface{}{ - "content": sb.String(), - "count": len(memos), - }, nil -} - -func errorResult(msg string) map[string]interface{} { - return map[string]interface{}{ - "isError": true, - "content": msg, - } -} diff --git a/third_party/homeagent-sdk/example/memo/plugin.json b/third_party/homeagent-sdk/example/memo/plugin.json deleted file mode 100644 index ef2c723..0000000 --- a/third_party/homeagent-sdk/example/memo/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "memo", - "version": "1.0.0", - "description": "备忘插件,支持创建、完成、列出备忘条目,自动提醒", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["memo", "reminder", "todo"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/qq/Makefile b/third_party/homeagent-sdk/example/qq/Makefile deleted file mode 100644 index 41f7c3d..0000000 --- a/third_party/homeagent-sdk/example/qq/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -# Build QQ plugin for HomeAgent -# Usage: make # build plugin.so -# make clean # remove build artifacts - -PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) -SDK_ROOT := $(realpath $(PLUGIN_DIR)../..) - -.PHONY: all clean - -all: plugin.so - -plugin.so: - cd $(SDK_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR) - -clean: - rm -f $(PLUGIN_DIR)plugin.so diff --git a/third_party/homeagent-sdk/example/qq/README.md b/third_party/homeagent-sdk/example/qq/README.md deleted file mode 100644 index fcc989c..0000000 --- a/third_party/homeagent-sdk/example/qq/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# qq 插件讲解 - -QQ 集成插件,通过 [NapCat](https://github.com/NapNeko/NapCat) OneBot 协议对接 QQ 机器人框架。 - -## 工具清单(15 个) - -| 工具 | 功能 | 源码 | -|------|------|------| -| `qq_get_message` | 获取通过中断通知的消息正文 | `handleGetMessage` | -| `qq_send_private_msg` | 发送私聊消息 | `handleSendPrivate` | -| `qq_send_group_msg` | 发送群消息 | `handleSendGroup` | -| `qq_send_file` | 发送文件/图片到私聊或群聊 | `handleSendFile` | -| `qq_get_history` | 获取历史消息 | `handleGetHistory` | -| `qq_get_groups` | 获取群列表 | `handleGetGroups` | -| `qq_get_friends` | 获取好友列表 | `handleGetFriends` | -| `qq_resolve_name` | 解析 QQ 号/群号为可读名称 | `handleResolveName` | -| `qq_get_group_member_info` | 获取群成员信息 | `handleGetGroupMemberInfo` | -| `qq_group_manage` | 群综合管理(踢人/禁言/改名等 18 个子命令) | `handleGroupManage` | -| `qq_friend_action` | 好友管理(删除/拉黑/同意请求等) | `handleFriendAction` | -| `qq_get_group_files` | 群文件操作(列表/搜索/下载) | `handleGetGroupFiles` | -| `qq_upload_group_file` | 上传文件到群 | `handleUploadGroupFile` | -| `qq_send_like` | 点赞/戳一戳 | `handleSendLike` | -| `qq_ocr_image` | 图片文字识别 | `handleOcrImage` | - -## 核心设计 - -### 消息接收:Webhook + 中断 - -插件启动一个 HTTP 服务器监听 NapCat 的回调 webhook,收到消息后先保存到内存循环缓冲区: - -```go -// plugin.go:handleWebhook -p.mu.Lock() -localID := p.nextID -p.nextID++ -msg := &SavedMessage{LocalID: localID, UserID: evt.UserID, ...} -p.messages = append(p.messages, msg) -// 保留最近 maxMessages(2000) 条 -``` - -然后通过 `InjectInterruptText` 将摘要推送给 LLM,LLM 再主动调用 `qq_get_message` 获取完整内容: - -```go -// plugin.go:handleWebhook - interrupt text -interrupt = fmt.Sprintf("来自%s的群聊消息,通过id%d使用%sget_message工具获取消息正文", - nickname, localID, tp) -p.sdk.InjectInterruptText(p.name, p.name, interrupt) -``` - -这种"先通知摘要,按需拉取全文"的设计避免了大量消息涌入 LLM 上下文。 - -### 管理员优先级标记 - -配置 `admin` 后,管理员消息的中断文本会加 `【重要!老大消息】` 前缀: - -```go -if p.adminID > 0 && evt.UserID == p.adminID { - interrupt = "【重要!老大消息】" + interrupt -} -``` - -### 消息过滤 - -`sensitiveFilter` 在发出消息前过滤敏感信息: - -```go -func (p *Plugin) sensitiveFilter(text string) string { - text = reAPIKey.ReplaceAllString(text, "$1=***") - text = reSKKey.ReplaceAllString(text, "sk-***") - text = reInternalIP.ReplaceAllString(text, "[IP]") - return text -} -``` - -保护 API Key、`sk-` 开头的密钥串、内网 IP 不被发到外部。 - -### NapCat HTTP 调用 - -所有 NapCat API 调用通过 `napcat()` 方法统一转发: - -```go -func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) { - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - resp, err := http.Post(url, "application/json", bytes.NewReader(data)) - // 返回原始 JSON 字符串 -} -``` - -NapCat API 地址通过配置 `plugin.qq.napcat_url` 设置。 - -### 消息存储 - -使用循环缓冲区(`[]*SavedMessage`),最多保留 2000 条。每条消息包含本地 ID、QQ 号、昵称、群号、群名、文本内容、时间戳。`qq_get_message` 通过 `local_id` 查找。 - -## 配置项 - -| Key | 默认值 | 说明 | -|-----|--------|------| -| `plugin.qq.listen` | `127.0.0.1:` | Webhook 监听地址 | -| `plugin.qq.napcat_url` | `http://127.0.0.1:` | NapCat HTTP API 基地址 | -| `plugin.qq.admin` | 空 | 管理员 QQ 号 | - -## 注意事项 - -- 依赖 NapCat 框架运行,需先启动 NapCat 并配置 webhook 指向本插件地址 -- 群管理中的破坏性操作(踢人、退群等)在描述中已写明需先请示管理员 -- `qq_get_message` 返回的消息对象包含完整字段,LLM 可据此判断消息类型和来源 diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go deleted file mode 100644 index f787806..0000000 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ /dev/null @@ -1,1051 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type SavedMessage struct { - LocalID int64 `json:"local_id"` - MessageID int64 `json:"message_id"` - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - GroupID int64 `json:"group_id,omitempty"` - GroupName string `json:"group_name,omitempty"` - MessageType string `json:"message_type"` - Text string `json:"text"` - Time int64 `json:"time"` -} - -const maxMessages = 2000 - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - messages []*SavedMessage - nextID int64 - listenAddr string - napcatURL string - remoteDir string - filesDir string - adminID int64 - botID int64 - botNickname string - dmPolicy string - groupPolicy string - allowFrom map[int64]struct{} - groupAllowFrom map[int64]struct{} - srv *http.Server - groupNameCache map[int64]string -} - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{ - name: name, - nextID: 1, - messages: make([]*SavedMessage, 0, maxMessages), - groupNameCache: make(map[int64]string), - allowFrom: make(map[int64]struct{}), - groupAllowFrom: make(map[int64]struct{}), - dmPolicy: "open", - groupPolicy: "open", - }, nil -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"}) - - settings := s.Settings() - - p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") - p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") - p.adminID = getSetting[int64](settings, "admin", 0) - p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open")) - p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open")) - p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", "")) - p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", "")) - - // 从 NapCat 自动获取 Bot 身份 - p.fetchBotInfo() - - tp := p.name + "_" - - botInfo := "" - if p.botNickname != "" { - botInfo = fmt.Sprintf("你的QQ昵称是%s", p.botNickname) - if p.botID > 0 { - botInfo += fmt.Sprintf(",QQ号是%d", p.botID) - } - botInfo += "。" - } - - // ---- 消息 ---- - p.regTool(s, tp+"get_message", botInfo+"获取通过中断通知的QQ消息正文。local_id来自中断文字中的id号。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "local_id": map[string]interface{}{"type": "integer", "description": "本地消息ID"}, - }, "required": []string{"local_id"}, - }, p.handleGetMessage) - - p.regTool(s, tp+"send_private_msg", "发送QQ私聊消息", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "message": map[string]interface{}{"type": "string", "description": "消息内容"}, - }, "required": []string{"user_id", "message"}, - }, p.handleSendPrivate) - - p.regTool(s, tp+"send_group_msg", "发送QQ群消息", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, - "message": map[string]interface{}{"type": "string", "description": "消息内容"}, - }, "required": []string{"group_id", "message"}, - }, p.handleSendGroup) - - p.regTool(s, tp+"send_file", "发送文件/图片到QQ(私聊或群聊)。文件先复制到remote目录供NapCat容器访问。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号(与group_id二选一)"}, - "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, - "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, - "as_image": map[string]interface{}{"type": "boolean", "description": "作为图片发送(true)还是作为文件(false,默认)"}, - }, - }, p.handleSendFile) - - p.regTool(s, tp+"get_history", "获取QQ群聊/私聊历史消息,用于回顾之前的对话上下文", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史(与group_id二选一)"}, - "count": map[string]interface{}{"type": "integer", "description": "拉取条数,默认10"}, - }, "required": []string{}, - }, p.handleGetHistory) - - // ---- 查询 ---- - p.regTool(s, tp+"get_groups", "获取QQ群列表,可按关键词搜索群名", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, - }, - }, p.handleGetGroups) - - p.regTool(s, tp+"get_friends", "获取QQ好友列表,可按昵称/备注关键词搜索", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, - }, - }, p.handleGetFriends) - - p.regTool(s, tp+"resolve_name", "将QQ号或群号解析为可读的用户昵称或群名称", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - }, - }, p.handleResolveName) - - p.regTool(s, tp+"get_group_member_info", "获取QQ群成员详细信息", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号"}, - }, "required": []string{"group_id", "user_id"}, - }, p.handleGetGroupMemberInfo) - - // ---- 群管理 ---- - p.regTool(s, tp+"group_manage", "QQ群综合管理。通过command参数执行各种操作:leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意:leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "command": map[string]interface{}{"type": "string", "description": "操作命令"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"}, - "message_id": map[string]interface{}{"type": "integer", "description": "消息ID(撤回/精华)"}, - "name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"}, - "card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"}, - "title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"}, - "enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"}, - "minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"}, - "count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"}, - "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"}, - "reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群(kick)"}, - "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"}, - }, - }, p.handleGroupManage) - - p.regTool(s, tp+"friend_action", "QQ好友管理:delete删除好友, block拉黑(删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意:涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"}, - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"}, - "remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出(block配合)"}, - "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, - }, - }, p.handleFriendAction) - - // ---- 文件 ---- - p.regTool(s, tp+"get_group_files", "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"}, - "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"}, - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"}, - "file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"}, - "filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"}, - }, - }, p.handleGetGroupFiles) - - p.regTool(s, tp+"upload_group_file", "上传文件到QQ群(通过base64编码发送,同时出现在群消息和群文件柜)", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, - "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, - "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, - }, "required": []string{"group_id", "file"}, - }, p.handleUploadGroupFile) - - // ---- 附加 ---- - p.regTool(s, tp+"send_like", "给QQ好友点赞/戳一戳", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "times": map[string]interface{}{"type": "integer", "description": "点赞次数1-20,默认1"}, - }, "required": []string{"user_id"}, - }, p.handleSendLike) - - p.regTool(s, tp+"ocr_image", "对QQ图片进行文字识别(调用NapCat OCR / 本地Tesseract)", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "image": map[string]interface{}{"type": "string", "description": "图片路径(本地路径或URL)"}, - "lang": map[string]interface{}{"type": "string", "description": "语言(chi_sim+eng默认, eng, chi_sim, chi_tra)"}, - }, "required": []string{"image"}, - }, p.handleOcrImage) - - s.RegisterStageOwnTools(sdk.StageBeforeToolcall, p.beforeOwnToolcall) - - // ---- HTTP server for NapCat webhook ---- - mux := http.NewServeMux() - mux.HandleFunc("/", p.handleWebhook) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"status":"ok"}`)) - }) - p.srv = &http.Server{Addr: p.listenAddr, Handler: mux} - go func() { - log.Printf("[qq] webhook %s napcat=%s", p.listenAddr, p.napcatURL) - if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("[qq] http: %v", err) - } - }() - - log.Printf("[qq] plugin started: %s (%d tools)", p.name, 14) - return nil -} - -func (p *Plugin) Stop() error { - if p.srv != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - p.srv.Shutdown(ctx) - } - return nil -} - -func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler) { - s.RegisterTool(name, sdk.ToolDef{Name: name, Description: desc, Parameters: params}, handler) -} - -// ======== Bot Identity ======== - -func (p *Plugin) fetchBotInfo() { - resp, err := p.rawNapcat("get_login_info", nil) - if err != nil { - log.Printf("[qq] fetch login info: %v", err) - return - } - var info struct { - Status string `json:"status"` - Data *struct { - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(resp), &info); err != nil { - log.Printf("[qq] parse login info: %v", err) - return - } - if info.Data != nil { - p.botID = info.Data.UserID - p.botNickname = info.Data.Nickname - log.Printf("[qq] bot identity: %s (%d)", p.botNickname, p.botID) - } -} - -// rawNapcat sends a request to NapCat and returns raw JSON string. -func (p *Plugin) rawNapcat(action string, params map[string]interface{}) (string, error) { - data, _ := json.Marshal(params) - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - resp, err := http.Post(url, "application/json", bytes.NewReader(data)) - if err != nil { - return "", fmt.Errorf("napcat %s: %w", action, err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - return string(body), nil -} - -// getSetting reads a setting from the SDK; returns fallback if unset or wrong type. -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 str, ok := v.(string); ok { - return any(str).(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 normalizePolicy(v string) string { - switch strings.ToLower(strings.TrimSpace(v)) { - case "allowlist": - return "allowlist" - case "disabled": - return "disabled" - default: - return "open" - } -} - -func parseIDSet(raw string) map[int64]struct{} { - out := make(map[int64]struct{}) - for _, part := range strings.Split(raw, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if n, err := strconv.ParseInt(part, 10, 64); err == nil { - out[n] = struct{}{} - } - } - return out -} - -// isAtBot checks if the message contains an @-mention of the bot. -func (p *Plugin) isAtBot(msg interface{}) bool { - segments, ok := msg.([]interface{}) - if !ok { - return false - } - botIDStr := strconv.FormatInt(p.botID, 10) - for _, seg := range segments { - s, ok := seg.(map[string]interface{}) - if !ok { - continue - } - if s["type"] == "at" { - if data, ok := s["data"].(map[string]interface{}); ok { - if qq, ok := data["qq"]; ok { - switch v := qq.(type) { - case string: - if v == botIDStr || v == "all" { - return true - } - case float64: - if int64(v) == p.botID { - return true - } - } - } - } - } - } - return false -} - -// ======== Webhook ======== - -func (p *Plugin) isDMAllowed(userID int64) bool { - switch p.dmPolicy { - case "disabled": - return false - case "allowlist": - _, ok := p.allowFrom[userID] - return ok - default: - return true - } -} - -func (p *Plugin) isGroupAllowed(groupID int64) bool { - switch p.groupPolicy { - case "disabled": - return false - case "allowlist": - _, ok := p.groupAllowFrom[groupID] - return ok - default: - return true - } -} - -func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error { - ctx.Lock() - defer ctx.Unlock() - if len(ctx.ToolCalls) == 0 { - return nil - } - tc := &ctx.ToolCalls[0] - switch tc.Name { - case p.name + "_send_private_msg", p.name + "_send_group_msg": - if msg, ok := tc.Arguments["message"].(string); ok { - tc.Arguments["message"] = p.sensitiveFilter(msg) - } - case p.name + "_send_file", p.name + "_upload_group_file": - if file, ok := tc.Arguments["file"].(string); ok { - tc.Arguments["file"] = p.sensitiveFilter(file) - } - } - if tc.Name == p.name+"_group_manage" { - cmd, _ := tc.Arguments["command"].(string) - if requiresConfirmGroupCommand(cmd) { - if ok, _ := tc.Arguments["confirm"].(bool); !ok { - msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) - ctx.Response = &msg - return nil - } - } - } - if tc.Name == p.name+"_friend_action" { - cmd, _ := tc.Arguments["command"].(string) - if requiresConfirmFriendCommand(cmd) { - if ok, _ := tc.Arguments["confirm"].(bool); !ok { - msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) - ctx.Response = &msg - return nil - } - } - } - return nil -} - -func requiresConfirmGroupCommand(cmd string) bool { - switch cmd { - case "leave", "kick", "ban", "unban", "rename", "mute-all", "set-card", "set-admin", "set-title", "recall", "pin-msg", "folder-create": - return true - default: - return false - } -} - -func requiresConfirmFriendCommand(cmd string) bool { - switch cmd { - case "delete", "block", "approve-friend", "reject-friend": - return true - default: - return false - } -} - -func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "", http.StatusMethodNotAllowed) - return - } - body, _ := io.ReadAll(r.Body) - var evt struct { - PostType string `json:"post_type"` - MessageType string `json:"message_type,omitempty"` - UserID int64 `json:"user_id,omitempty"` - GroupID int64 `json:"group_id,omitempty"` - RawMessage string `json:"raw_message,omitempty"` - Message interface{} `json:"message,omitempty"` - Time int64 `json:"time"` - Sender *struct { - Nickname string `json:"nickname"` - Card string `json:"card,omitempty"` - } `json:"sender,omitempty"` - } - if json.Unmarshal(body, &evt) != nil || evt.PostType != "message" { - w.WriteHeader(http.StatusOK) - return - } - - text := evt.RawMessage - if text == "" { - if s, ok := evt.Message.(string); ok { - text = s - } - } - if text == "" { - w.WriteHeader(http.StatusOK) - return - } - - if evt.MessageType == "private" { - if !p.isDMAllowed(evt.UserID) { - w.WriteHeader(http.StatusOK) - return - } - } - if evt.MessageType == "group" { - if !p.isGroupAllowed(evt.GroupID) { - w.WriteHeader(http.StatusOK) - return - } - // 群消息必须 @ 机器人才响应 - if p.botID > 0 && !p.isAtBot(evt.Message) { - w.WriteHeader(http.StatusOK) - return - } - } - - nickname := "" - if evt.Sender != nil { - nickname = evt.Sender.Nickname - if evt.Sender.Card != "" { - nickname = evt.Sender.Card - } - } - - p.mu.Lock() - localID := p.nextID - p.nextID++ - - msg := &SavedMessage{ - LocalID: localID, UserID: evt.UserID, Nickname: nickname, - GroupID: evt.GroupID, MessageType: evt.MessageType, Text: text, Time: evt.Time, - } - groupName := "" - if evt.MessageType == "group" { - if n, ok := p.groupNameCache[evt.GroupID]; ok { - groupName = n - } else { - groupName = fmt.Sprintf("%d", evt.GroupID) - } - msg.GroupName = groupName - } - p.messages = append(p.messages, msg) - if len(p.messages) > maxMessages { - p.messages = p.messages[1:] - } - - tp := p.name + "_" - var interrupt string - if evt.MessageType == "group" { - interrupt = fmt.Sprintf("来自%s的(%s)群聊消息,通过id%d使用%sget_message工具获取消息正文。获取后必须使用%ssend_group_msg工具回复该群聊", nickname, groupName, localID, tp, tp) - } else { - interrupt = fmt.Sprintf("来自%s的私聊消息,通过id%d使用%sget_message工具获取消息正文。获取后必须使用%ssend_private_msg工具回复对方", nickname, localID, tp, tp) - } - if p.adminID > 0 && evt.UserID == p.adminID { - interrupt = "【重要!老大消息】" + interrupt - } - p.mu.Unlock() - - if p.sdk != nil { - p.sdk.InjectInterruptText(p.name, p.name, interrupt) - } - w.WriteHeader(http.StatusOK) -} - -// ======== Tool Handlers ======== - -func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { - id, err := convInt64(args["local_id"]) - if err != nil { - return map[string]interface{}{ - "content": fmt.Sprintf("无效的 local_id 参数,请传入整数类型的消息ID"), - "error": err.Error(), - }, nil - } - p.mu.RLock() - defer p.mu.RUnlock() - for _, m := range p.messages { - if m.LocalID == id { - return m, nil - } - } - return map[string]interface{}{ - "content": fmt.Sprintf("消息 %d 未找到。可能的原因:消息已被处理过期,或插件重启后本地缓存已清空。请使用 qq_get_history 工具从 NapCat 拉取历史消息。", id), - "local_id": id, - "not_found": true, - }, nil -} - -func (p *Plugin) handleSendPrivate(args map[string]interface{}) (interface{}, error) { - uid, _ := convInt64(args["user_id"]) - msg := p.sensitiveFilter(args["message"].(string)) - return p.napcat("send_private_msg", map[string]interface{}{"user_id": uid, "message": msg}) -} - -func (p *Plugin) handleSendGroup(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - msg := p.sensitiveFilter(args["message"].(string)) - return p.napcat("send_group_msg", map[string]interface{}{"group_id": gid, "message": msg}) -} - -func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - if gerr != nil && uerr != nil { - return nil, fmt.Errorf("need group_id or user_id") - } - filePath, _ := args["file"].(string) - if filePath == "" { - return nil, fmt.Errorf("need file path") - } - name, _ := args["name"].(string) - if name == "" { - name = filepath.Base(filePath) - } - name = p.sensitiveFilter(name) - asImage, _ := args["as_image"].(bool) - - // copy to remote dir for NapCat container access - dest := filepath.Join(p.remoteDir, name) - srcData, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("read file: %w", err) - } - if err := os.WriteFile(dest, srcData, 0644); err != nil { - return nil, fmt.Errorf("write remote: %w", err) - } - - uri := fmt.Sprintf("file:///app/files/%s", name) - var cqMsg string - if asImage { - cqMsg = fmt.Sprintf("[CQ:image,file=%s]", uri) - } else { - cqMsg = fmt.Sprintf("[CQ:file,file=%s,title=%s]", uri, name) - } - - params := map[string]interface{}{"message": cqMsg} - if gerr == nil { - params["group_id"] = gid - return p.napcat("send_group_msg", params) - } - params["user_id"] = uid - return p.napcat("send_private_msg", params) -} - -func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - - var endpoint string - var params map[string]interface{} - if gerr == nil { - endpoint = "get_group_msg_history" - params = map[string]interface{}{"group_id": gid, "count": count} - } else if uerr == nil { - endpoint = "get_friend_msg_history" - params = map[string]interface{}{"user_id": uid, "count": count} - } else { - return nil, fmt.Errorf("need group_id or user_id") - } - - data, err := p.napcat(endpoint, params) - if err != nil { - return nil, err - } - return data, nil -} - -func (p *Plugin) handleGetGroups(args map[string]interface{}) (interface{}, error) { - return p.napcat("get_group_list", map[string]interface{}{}) -} - -func (p *Plugin) handleGetFriends(args map[string]interface{}) (interface{}, error) { - return p.napcat("get_friend_list", map[string]interface{}{}) -} - -func (p *Plugin) handleResolveName(args map[string]interface{}) (interface{}, error) { - if uid, err := convInt64(args["user_id"]); err == nil { - return p.napcat("get_stranger_info", map[string]interface{}{"user_id": uid, "no_cache": true}) - } - if gid, err := convInt64(args["group_id"]); err == nil { - return p.napcat("get_group_info", map[string]interface{}{"group_id": gid, "no_cache": true}) - } - return nil, fmt.Errorf("need user_id or group_id") -} - -func (p *Plugin) handleGetGroupMemberInfo(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) -} - -func (p *Plugin) handleGroupManage(args map[string]interface{}) (interface{}, error) { - cmd, _ := args["command"].(string) - if cmd == "" { - return nil, fmt.Errorf("need command") - } - if requiresConfirmGroupCommand(cmd) { - if ok, _ := args["confirm"].(bool); !ok { - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil - } - } - - switch cmd { - case "group-list": - return p.napcat("get_group_list", map[string]interface{}{}) - case "group-info", "member-list", "member-info", "at-all-remain", "msg-history": - gid, _ := convInt64(args["group_id"]) - if cmd == "msg-history" { - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - return p.napcat("get_group_msg_history", map[string]interface{}{"group_id": gid, "count": count}) - } - if cmd == "member-info" { - uid, _ := convInt64(args["user_id"]) - return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) - } - if cmd == "at-all-remain" { - return p.napcat("get_group_at_all_remain", map[string]interface{}{"group_id": gid}) - } - if cmd == "group-info" { - return p.napcat("get_group_info", map[string]interface{}{"group_id": gid}) - } - return p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) - - case "list-files": - gid, _ := convInt64(args["group_id"]) - folderID, _ := args["folder_id"].(string) - if folderID != "" { - return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) - } - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "pending-requests": - return p.napcat("get_group_system_msg", map[string]interface{}{}) - - case "leave": - gid, _ := convInt64(args["group_id"]) - return p.napcat("set_group_leave", map[string]interface{}{"group_id": gid}) - - case "kick": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - reject, _ := args["reject_add"].(bool) - return p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": reject}) - - case "ban": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - minutes := 10 - if m, err := convInt64(args["minutes"]); err == nil { - minutes = int(m) - } - return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": minutes * 60}) - - case "unban": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": 0}) - - case "rename": - gid, _ := convInt64(args["group_id"]) - name, _ := args["name"].(string) - return p.napcat("set_group_name", map[string]interface{}{"group_id": gid, "group_name": name}) - - case "mute-all": - gid, _ := convInt64(args["group_id"]) - enable, _ := args["enable"].(bool) - return p.napcat("set_group_whole_ban", map[string]interface{}{"group_id": gid, "enable": enable}) - - case "set-card": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - card, _ := args["card"].(string) - return p.napcat("set_group_card", map[string]interface{}{"group_id": gid, "user_id": uid, "card": card}) - - case "set-admin": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - enable, _ := args["enable"].(bool) - return p.napcat("set_group_admin", map[string]interface{}{"group_id": gid, "user_id": uid, "enable": enable}) - - case "set-title": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - title, _ := args["title"].(string) - return p.napcat("set_group_special_title", map[string]interface{}{"group_id": gid, "user_id": uid, "special_title": title}) - - case "recall": - mid, _ := convInt64(args["message_id"]) - return p.napcat("delete_msg", map[string]interface{}{"message_id": mid}) - - case "pin-msg": - mid, _ := convInt64(args["message_id"]) - return p.napcat("set_essence_msg", map[string]interface{}{"message_id": mid}) - - case "folder-create": - gid, _ := convInt64(args["group_id"]) - name, _ := args["name"].(string) - return p.napcat("create_group_file_folder", map[string]interface{}{"group_id": gid, "name": name}) - - default: - return nil, fmt.Errorf("unknown group_manage command: %s", cmd) - } -} - -func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, error) { - cmd, _ := args["command"].(string) - if requiresConfirmFriendCommand(cmd) { - if ok, _ := args["confirm"].(bool); !ok { - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil - } - } - switch cmd { - case "list-friends": - return p.napcat("get_friend_list", map[string]interface{}{}) - case "delete": - uid, _ := convInt64(args["user_id"]) - return p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) - case "block": - uid, _ := convInt64(args["user_id"]) - // delete friend - p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) - // kick from groups - if gid, err := convInt64(args["group_id"]); err == nil { - p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) - } else { - grps, _ := p.napcat("get_group_list", map[string]interface{}{}) - if list, ok := grps.([]interface{}); ok { - for _, g := range list { - if m, ok := g.(map[string]interface{}); ok { - if gid, ok := m["group_id"].(float64); ok { - p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true}) - } - } - } - } - } - return `{"status":"ok","message":"blocked"}`, nil - case "approve-friend": - flag, _ := args["flag"].(string) - remark, _ := args["remark"].(string) - return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": true, "remark": remark}) - case "reject-friend": - flag, _ := args["flag"].(string) - return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": false}) - default: - return nil, fmt.Errorf("unknown friend_action command: %s", cmd) - } -} - -func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - cmd, _ := args["command"].(string) - - switch cmd { - case "list": - folderID, _ := args["folder_id"].(string) - if folderID != "" { - return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) - } - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "search": - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "download": - fileID, _ := args["file_id"].(string) - filename, _ := args["filename"].(string) - if filename == "" { - filename = fmt.Sprintf("group_file_%s", fileID) - } - // get download URL - resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID}) - if err != nil { - return nil, err - } - respStr, ok := resp.(string) - if !ok { - return resp, nil - } - // parse URL from response - var parsed struct { - Data struct { - URL string `json:"url"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { - return resp, nil - } - dlURL := parsed.Data.URL - httpResp, err := http.Get(dlURL) - if err != nil { - return nil, fmt.Errorf("download: %w", err) - } - defer httpResp.Body.Close() - content, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("read download: %w", err) - } - os.MkdirAll(p.filesDir, 0755) - savePath := filepath.Join(p.filesDir, filename) - if err := os.WriteFile(savePath, content, 0644); err != nil { - return nil, fmt.Errorf("save: %w", err) - } - return map[string]interface{}{ - "status": "ok", "path": savePath, "filename": filename, "size": len(content), - }, nil - - default: - return nil, fmt.Errorf("unknown get_group_files command: %s", cmd) - } -} - -func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - filePath, _ := args["file"].(string) - name, _ := args["name"].(string) - if name == "" { - name = filepath.Base(filePath) - } - name = p.sensitiveFilter(name) - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - b64 := fmt.Sprintf("base64://%s", string(data)) - - resp, err := p.napcat("send_group_msg", map[string]interface{}{ - "group_id": gid, - "message": []map[string]interface{}{ - {"type": "file", "data": map[string]interface{}{"file": b64, "name": name}}, - }, - }) - if err != nil { - return nil, err - } - return map[string]interface{}{"status": "ok", "file": name, "napcat": resp}, nil -} - -func (p *Plugin) handleSendLike(args map[string]interface{}) (interface{}, error) { - uid, _ := convInt64(args["user_id"]) - times := 1 - if t, err := convInt64(args["times"]); err == nil && t > 0 && t <= 20 { - times = int(t) - } - return p.napcat("send_like", map[string]interface{}{"user_id": uid, "times": times}) -} - -func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error) { - image, _ := args["image"].(string) - lang, _ := args["lang"].(string) - if lang == "" { - lang = "chi_sim+eng" - } - - // If local file, copy to remote dir for NapCat - if !strings.HasPrefix(image, "http://") && !strings.HasPrefix(image, "https://") { - dest := filepath.Join(p.remoteDir, filepath.Base(image)) - src, err := os.ReadFile(image) - if err == nil { - os.WriteFile(dest, src, 0644) - image = fmt.Sprintf("file:///app/files/%s", filepath.Base(image)) - } - } - - return p.napcat("ocr_image", map[string]interface{}{"image": image}) -} - -// ======== NapCat HTTP Client ======== - -func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) { - data, _ := json.Marshal(params) - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - - resp, err := http.Post(url, "application/json", bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("napcat %s: %w", action, err) - } - defer resp.Body.Close() - - var raw json.RawMessage - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, fmt.Errorf("napcat decode %s: %w", action, err) - } - return string(raw), nil -} - -// ======== Helpers ======== - -func main() {} - -var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`) -var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`) -var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) - -func (p *Plugin) sensitiveFilter(text string) string { - if p.remoteDir != "" { - text = strings.ReplaceAll(text, p.remoteDir, "[remote]") - } - if p.filesDir != "" { - text = strings.ReplaceAll(text, p.filesDir, "[files]") - } - - text = reAPIKey.ReplaceAllString(text, "$1=***") - text = reSKKey.ReplaceAllString(text, "sk-***") - text = reInternalIP.ReplaceAllString(text, "[IP]") - return text -} - -func convInt64(v interface{}) (int64, error) { - switch n := v.(type) { - case int64: - return n, nil - case float64: - return int64(n), nil - case int: - return int64(n), nil - case json.Number: - return n.Int64() - case string: - return strconv.ParseInt(n, 10, 64) - } - return 0, fmt.Errorf("cannot convert %T to int64", v) -} diff --git a/third_party/homeagent-sdk/example/qq/plugin.json b/third_party/homeagent-sdk/example/qq/plugin.json deleted file mode 100644 index f168ea3..0000000 --- a/third_party/homeagent-sdk/example/qq/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "qq", - "version": "1.0.0", - "description": "QQ 集成插件,对接 NapCat OneBot 框架,支持消息收发、群管理、好友管理等", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["qq", "napcat", "onebot", "messaging"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/web/README.md b/third_party/homeagent-sdk/example/web/README.md deleted file mode 100644 index c93d3b2..0000000 --- a/third_party/homeagent-sdk/example/web/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# web 插件讲解 - -网络工具插件,提供网页搜索和内容抓取功能。 - -## 工具清单 - -| 工具 | 功能 | 源码 | -|------|------|------| -| `web_search` | 通过 DuckDuckGo 搜索网页 | `handleSearch` | -| `web_fetch` | 抓取指定 URL 的内容 | `handleFetch` | - -## 核心设计 - -### 搜索实现 - -`web_search` 使用 DuckDuckGo 的 HTML 搜索页面(非 API,免注册): - -```go -// plugin.go:handleSearch -url := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) -resp, err := p.httpClient().Get(url) -``` - -解析策略:扫描 HTML 查找 `:` | - -## 注意事项 - -- DuckDuckGo HTML 格式可能随网站更新变化,如果搜索结果解析失败需调整 `parseSearchResults` 中的 HTML 标记匹配 -- SSRF 防护默认阻止内网请求,如需访问内网资源需修改 `isInternalIP` 逻辑 -- 搜索结果依赖 DuckDuckGo 可用性,在国内使用建议配置代理 diff --git a/third_party/homeagent-sdk/example/web/plugin.go b/third_party/homeagent-sdk/example/web/plugin.go deleted file mode 100644 index 4e31b73..0000000 --- a/third_party/homeagent-sdk/example/web/plugin.go +++ /dev/null @@ -1,567 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "net/url" - "strings" - "sync" - "time" - "unicode" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - timeout int - proxy string - client *http.Client -} - -func newHTTPClient(timeout int, proxyURL string) *http.Client { - transport := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: time.Duration(timeout) * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - TLSHandshakeTimeout: time.Duration(timeout) * time.Second, - ResponseHeaderTimeout: time.Duration(timeout) * time.Second, - } - if proxyURL != "" { - u, err := url.Parse(proxyURL) - if err == nil { - transport.Proxy = http.ProxyURL(u) - } - } - return &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - Transport: transport, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 5 { - return fmt.Errorf("too many redirects") - } - return nil - }, - } -} - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "plugin.web.timeout", - Default: "30", - Type: "int", - DisplayName: "HTTP 超时(秒)", - Description: "Web fetch 和搜索的 HTTP 请求超时时间", - Category: "web", - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "plugin.web.proxy", - Default: "", - Type: "string", - DisplayName: "HTTP 代理", - Description: "HTTP 代理地址,如 http://:。为空则不使用代理", - Category: "web", - }) - - t := getSetting[float64](s.Settings(), "timeout", 30) - p.timeout = int(t) - if p.timeout < 5 { - p.timeout = 5 - } - if p.timeout > 120 { - p.timeout = 120 - } - - p.proxy = getSetting[string](s.Settings(), "proxy", "") - p.client = newHTTPClient(p.timeout, p.proxy) - - tp := p.name + "_" - - s.RegisterTool(tp+"search", sdk.ToolDef{ - Name: tp + "search", - Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{"type": "string", "description": "Search query"}, - "count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"}, - }, - "required": []string{"query"}, - }, - }, p.handleSearch) - - s.RegisterTool(tp+"fetch", sdk.ToolDef{ - Name: tp + "fetch", - Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"}, - "max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"}, - }, - "required": []string{"url"}, - }, - }, p.handleFetch) - - proxyMsg := "" - if p.proxy != "" { - proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy) - } - log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg) - return nil -} - -func (p *Plugin) Stop() error { - p.client.CloseIdleConnections() - log.Printf("[%s] stopped", p.name) - return nil -} - -// ── SSRF 保护 ────────────────────────────────────────────── - -var privateCIDRs []*net.IPNet - -func init() { - cidrs := []string{ - "127.0.0.0/8", // loopback - "10.0.0.0/8", // private - "172.16.0.0/12", // private - "192.168.0.0/16", // private - "100.64.0.0/10", // carrier-grade NAT - "169.254.0.0/16", // link-local - "::1/128", // IPv6 loopback - "fc00::/7", // IPv6 unique local - "fe80::/10", // IPv6 link-local - } - for _, c := range cidrs { - _, n, err := net.ParseCIDR(c) - if err == nil { - privateCIDRs = append(privateCIDRs, n) - } - } -} - -func isPrivateIP(ip net.IP) bool { - for _, n := range privateCIDRs { - if n.Contains(ip) { - return true - } - } - return false -} - -func (p *Plugin) ssrfCheck(rawURL string) error { - u, err := url.Parse(rawURL) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme) - } - - host := u.Hostname() - ips, err := net.LookupHost(host) - if err != nil { - return fmt.Errorf("DNS lookup failed for %s: %w", host, err) - } - - for _, ip := range ips { - parsed := net.ParseIP(ip) - if parsed == nil { - continue - } - if isPrivateIP(parsed) { - return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip) - } - } - return nil -} - -// ── DuckDuckGo 搜索 ──────────────────────────────────────── - -type ddgResult struct { - Title string - URL string - Snippet string -} - -func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) { - form := url.Values{"q": {query}} - req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - - resp, err := p.client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read body: %w", err) - } - - return parseDDGResults(string(body), count), nil -} - -func parseDDGResults(html string, count int) []ddgResult { - var results []ddgResult - - // Find all result blocks:
...
- bodyMarker := `result__body"` - for i := 0; i < len(html); i++ { - idx := strings.Index(html[i:], bodyMarker) - if idx < 0 { - break - } - i += idx - - // Find closing - closeIdx := findClosingTag(html, i, "") - if closeIdx < 0 { - break - } - block := html[i : closeIdx+6] - - r := parseSingleDDGResult(block) - if r.URL != "" { - results = append(results, r) - if len(results) >= count { - break - } - } - - i = closeIdx + 6 - } - - return results -} - -func findClosingTag(s string, start int, tag string) int { - depth := 1 - pos := start - for pos < len(s) { - nextOpen := strings.Index(s[pos:], `= 0 && nextOpen < nextClose { - depth++ - pos += nextOpen + 4 - } else { - depth-- - if depth == 0 { - return pos + nextClose - } - pos += nextClose + len(tag) - } - } - return -1 -} - -func parseSingleDDGResult(block string) ddgResult { - var r ddgResult - - // Extract URL and title from:
TITLE - urlMarker := `class="result__a" href="` - uIdx := strings.Index(block, urlMarker) - if uIdx >= 0 { - start := uIdx + len(urlMarker) - end := strings.Index(block[start:], `"`) - if end >= 0 { - r.URL = block[start : start+end] - } - - aStart := strings.Index(block[start+end:], `>`) - if aStart >= 0 { - titleStart := start + end + aStart + 1 - aEnd := strings.Index(block[titleStart:], ``) - if aEnd >= 0 { - r.Title = stripTags(block[titleStart : titleStart+aEnd]) - } - } - } - - // Extract snippet: ... - snippetMarkers := []string{ - `= 0 { - aStart := strings.Index(block[sIdx:], `>`) - if aStart >= 0 { - snipStart := sIdx + aStart + 1 - snipEnd := strings.Index(block[snipStart:], ``) - if snipEnd < 0 { - snipEnd = strings.Index(block[snipStart:], ``) - } - if snipEnd >= 0 { - r.Snippet = stripTags(block[snipStart : snipStart+snipEnd]) - } - } - break - } - } - - return r -} - -// ── Web Fetch ────────────────────────────────────────────── - -func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) { - rawURL, _ := args["url"].(string) - if rawURL == "" { - return errorResult("url is required"), nil - } - - maxChars := 20000 - if v, ok := args["max_chars"].(float64); ok && v > 0 { - maxChars = int(v) - } - if maxChars > 500000 { - maxChars = 500000 - } - - if err := p.ssrfCheck(rawURL); err != nil { - return errorResult(err.Error()), nil - } - - req, err := http.NewRequest("GET", rawURL, nil) - if err != nil { - return errorResult("invalid URL: " + err.Error()), nil - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - - resp, err := p.client.Do(req) - if err != nil { - return errorResult("fetch failed: " + err.Error()), nil - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 400 { - return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000)) - if err != nil { - return errorResult("read error: " + err.Error()), nil - } - - rawText := string(body) - - // Extract readable content based on content type - ct := resp.Header.Get("Content-Type") - var extracted string - if strings.Contains(ct, "text/html") { - extracted = htmlToText(rawText) - } else if strings.Contains(ct, "application/json") { - // Pretty-print JSON - var v interface{} - if json.Unmarshal(body, &v) == nil { - if pretty, err := json.MarshalIndent(v, "", " "); err == nil { - extracted = string(pretty) - } else { - extracted = rawText - } - } else { - extracted = rawText - } - } else { - extracted = rawText - } - - // Clean up and truncate - extracted = strings.TrimSpace(extracted) - if len(extracted) > maxChars { - extracted = extracted[:maxChars] + "\n\n[Content truncated]" - } - - if extracted == "" { - extracted = "(empty content)" - } - - return map[string]interface{}{ - "content": extracted, - "details": map[string]interface{}{ - "url": rawURL, - "status": resp.StatusCode, - "content_type": ct, - }, - }, nil -} - -// ── HTML → 文本 ────────────────────────────────────────────── - -func htmlToText(html string) string { - // Remove scripts - for { - start := strings.Index(strings.ToLower(html), "") - if end < 0 { - break - } - html = html[:start] + html[start+end+9:] - } - - // Remove styles - for { - start := strings.Index(strings.ToLower(html), "") - if end < 0 { - break - } - html = html[:start] + html[start+end+8:] - } - - // Replace block-level tags with newlines - for _, tag := range []string{"

", "", "", "", "", "", "", "", "", "", "", ""} { - html = strings.ReplaceAll(html, tag, "\n") - } - - // Remove remaining tags - html = stripTags(html) - - // Decode common entities - html = strings.ReplaceAll(html, "&", "&") - html = strings.ReplaceAll(html, "<", "<") - html = strings.ReplaceAll(html, ">", ">") - html = strings.ReplaceAll(html, """, "\"") - html = strings.ReplaceAll(html, "'", "'") - html = strings.ReplaceAll(html, " ", " ") - - // Collapse whitespace - lines := strings.Split(html, "\n") - var cleaned []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Collapse internal whitespace - in := []rune(line) - var out []rune - space := false - for _, r := range in { - if unicode.IsSpace(r) { - if !space { - out = append(out, ' ') - space = true - } - } else { - out = append(out, r) - space = false - } - } - cleaned = append(cleaned, string(out)) - } - - return strings.Join(cleaned, "\n") -} - -func stripTags(s string) string { - var out strings.Builder - inTag := false - for _, r := range s { - if r == '<' { - inTag = true - continue - } - if r == '>' { - inTag = false - continue - } - if !inTag { - out.WriteRune(r) - } - } - return out.String() -} - -// ── Search 处理 ────────────────────────────────────────────── - -func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) { - query, _ := args["query"].(string) - if query == "" { - return errorResult("query is required"), nil - } - - count := 5 - if v, ok := args["count"].(float64); ok && v > 0 { - count = int(v) - } - if count < 1 { - count = 1 - } - if count > 20 { - count = 20 - } - - results, err := p.ddgSearch(query, count) - if err != nil { - return errorResult("search failed: " + err.Error()), nil - } - - if len(results) == 0 { - return map[string]interface{}{ - "content": "No results found.", - }, nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query)) - for i, r := range results { - sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)) - } - - return map[string]interface{}{ - "content": strings.TrimSpace(sb.String()), - }, nil -} - -// ── 工具函数 ────────────────────────────────────────────── - -func errorResult(msg string) map[string]interface{} { - return map[string]interface{}{ - "isError": true, - "content": msg, - } -} - -func getSetting[T any](s sdk.SettingsAPI, key string, def T) T { - v, err := s.Get(key) - if err != nil || v == nil { - return def - } - val, ok := v.(T) - if !ok { - return def - } - return val -} diff --git a/third_party/homeagent-sdk/example/web/plugin.json b/third_party/homeagent-sdk/example/web/plugin.json deleted file mode 100644 index cc32bf3..0000000 --- a/third_party/homeagent-sdk/example/web/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "web", - "version": "1.0.0", - "description": "网络工具插件,提供网页搜索和内容抓取功能", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["web", "search", "http"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/go.mod b/third_party/homeagent-sdk/go.mod deleted file mode 100644 index a31a79e..0000000 --- a/third_party/homeagent-sdk/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module gitcode.com/JianFeeeee/homeagent-sdk - -go 1.21 diff --git a/third_party/homeagent-sdk/hack/plugin-dev/packager.sh b/third_party/homeagent-sdk/hack/plugin-dev/packager.sh deleted file mode 100755 index 5b9b627..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/packager.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# HomeAgent 插件打包工具 -# 将插件目录打包为 .hmap 分发包 -# 用法: ./packager.sh [输出路径] -# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap - -PLUGIN_DIR="${1:-}" -OUTPUT="${2:-}" - -if [ -z "$PLUGIN_DIR" ]; then - echo "用法: $0 [输出路径]" - echo "示例: $0 ./plugins/myplugin ./dist/myplugin-1.0.0.hmap" - exit 1 -fi - -PLUGIN_DIR="$(realpath "$PLUGIN_DIR")" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" - -# 验证 -if [ ! -f "$PLUGIN_DIR/plugin.json" ]; then - echo "错误: 不存在 plugin.json: $PLUGIN_DIR" - exit 1 -fi - -VERSION="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['version'])" 2>/dev/null || echo "unknown")" - -if [ -z "$OUTPUT" ]; then - mkdir -p dist - OUTPUT="$(realpath "dist/${PLUGIN_NAME}-${VERSION}.hmap")" -fi - -echo "🔨 打包插件: $PLUGIN_NAME v$VERSION" -echo " 源目录: $PLUGIN_DIR" -echo " 输出: $OUTPUT" - -# 检查入口文件 -ENTRY="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['entry'])" 2>/dev/null || true)" -if [ -n "$ENTRY" ] && [ ! -f "$PLUGIN_DIR/$ENTRY" ]; then - echo "⚠️ 入口文件不存在: $ENTRY" - echo " 请先编译: cd $PLUGIN_DIR && make" - exit 1 -fi - -# 检查已编译的 .so -if [ -f "$PLUGIN_DIR/plugin.so" ] && [ "$(stat -c %Y "$PLUGIN_DIR/plugin.so" 2>/dev/null)" -lt "$(stat -c %Y "$PLUGIN_DIR/plugin.go" 2>/dev/null)" ]; then - echo "⚠️ plugin.so 比 plugin.go 旧,建议重新编译" - echo " 请执行: cd $PLUGIN_DIR && make" -fi - -cd "$PLUGIN_DIR" -zip -r "$OUTPUT" . -x "*.git*" "Makefile" ".gitignore" "*.go" "go.mod" "go.sum" "*.test" "testdata/*" "_*" 2>&1 | tail -3 - -echo "" -echo "✅ 打包完成: $OUTPUT" -echo " 大小: $(ls -lh "$OUTPUT" | awk '{print $5}')" -echo "" -echo "安装方式:" -echo " 1. WebUI 插件管理 → 上传安装" -echo " 2. AI 对话: 使用 plugin_install 工具并上传 URL" diff --git a/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh b/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh deleted file mode 100755 index 83f6d77..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# HomeAgent 插件脚手架生成工具 -# 用法: ./scaffold.sh [输出目录] -# 示例: ./scaffold.sh myplugin ./plugins/myplugin - -NAME="${1:-}" -OUTDIR="${2:-./plugins/$NAME}" - -if [ -z "$NAME" ]; then - echo "用法: $0 [输出目录]" - echo "示例: $0 myplugin ./plugins/myplugin" - exit 1 -fi - -if [ -d "$OUTDIR" ]; then - echo "错误: 目标目录已存在: $OUTDIR" - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TEMPLATE_DIR="$SCRIPT_DIR/templates" - -mkdir -p "$OUTDIR" - -# 替换模板中的占位符 -sed -e "s/{{.Name}}/$NAME/g" \ - -e "s/{{.Version}}/0.1.0/g" \ - -e "s/{{.Description}}//g" \ - -e "s/{{.Author}}//g" \ - "$TEMPLATE_DIR/plugin.json.tmpl" > "$OUTDIR/plugin.json" - -cp "$TEMPLATE_DIR/plugin.go.tmpl" "$OUTDIR/plugin.go" -cp "$TEMPLATE_DIR/Makefile.tmpl" "$OUTDIR/Makefile" -cp "$TEMPLATE_DIR/gitignore.tmpl" "$OUTDIR/.gitignore" - -echo "✅ 插件脚手架已生成: $OUTDIR" -echo "" -echo "下一步:" -echo " 1. 编辑 $OUTDIR/plugin.go 实现业务逻辑" -echo " 2. 编辑 $OUTDIR/plugin.json 完善元信息" -echo " 3. cd $OUTDIR && make # 编译 plugin.so" -echo " 4. make package # 打包为 .hmap 分发包" -echo " 5. 通过 WebUI 或 plugin_install 工具安装" diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl deleted file mode 100644 index f0b857e..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -# Build external Go plugin for HomeAgent -# Usage: make # build plugin.so -# make clean # remove plugin.so - -PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) -SDK_ROOT := $(realpath $(PLUGIN_DIR)../..) -PLUGIN_NAME := $(notdir $(realpath $(PLUGIN_DIR))) - -.PHONY: all clean - -all: plugin.so - -plugin.so: - cd $(SDK_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR) - -clean: - rm -f $(PLUGIN_DIR)plugin.so diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl deleted file mode 100644 index 842ab43..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl +++ /dev/null @@ -1,2 +0,0 @@ -plugin.so -*.hmap diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl deleted file mode 100644 index a7d1378..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "log" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK -} - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{ - name: name, - }, nil -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - - tp := p.name + "_" - - s.RegisterTool(tp+"example", sdk.ToolDef{ - Name: tp + "example", - Description: "示例工具 - 请替换为实现", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "input": map[string]interface{}{"type": "string", "description": "输入参数"}, - }, - "required": []string{"input"}, - }, - }, p.handleExample) - - log.Printf("[%s] plugin started", p.name) - return nil -} - -func (p *Plugin) Stop() error { - return nil -} - -func (p *Plugin) handleExample(args map[string]interface{}) (interface{}, error) { - input, _ := args["input"].(string) - return map[string]interface{}{ - "echo": input, - }, nil -} - -func main() {} diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl deleted file mode 100644 index de06aed..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "{{.Name}}", - "version": "{{.Version}}", - "description": "{{.Description}}", - "author": "{{.Author}}", - "license": "MIT", - "entry": "plugin.so", - "min_version": "1.0.0", - "tags": ["{{.Name}}"] -} diff --git a/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go b/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go deleted file mode 100644 index c06dd81..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go +++ /dev/null @@ -1,237 +0,0 @@ -// Package plugintest provides a test harness for external HomeAgent plugins. -// -// Usage: -// -// import "gitcode.com/JianFeeeee/homeagent-sdk/hack/plugin-dev/testharness" -// -// func TestMyPlugin(t *testing.T) { -// h := testharness.New(t, "./path/to/plugin.so") -// defer h.Close() -// -// result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{ -// "input": "hello", -// }) -// if err != nil { -// t.Fatal(err) -// } -// t.Logf("result: %v", result) -// } -package plugintest - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "plugin" - "strings" - "sync" - "testing" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -// Harness is a test harness for loading and testing external Go plugins. -type Harness struct { - t *testing.T - plug sdk.Plugin - sdk *sdk.PluginSDK - mu sync.Mutex - tools map[string]sdk.ToolHandler - stages map[sdk.Stage][]sdk.StageHandler - setting *mockSettings -} - -// New loads a plugin .so and starts it with a mock SDK. -// soPath is the path to the compiled plugin.so file. -func New(t *testing.T, soPath string) *Harness { - t.Helper() - - absPath, err := filepath.Abs(soPath) - if err != nil { - t.Fatalf("abs path: %v", err) - } - if _, err := os.Stat(absPath); err != nil { - t.Fatalf("plugin not found: %s", absPath) - } - - pkg, err := plugin.Open(absPath) - if err != nil { - t.Fatalf("plugin.Open: %v", err) - } - - sym, err := pkg.Lookup("NewPlugin") - if err != nil { - t.Fatalf("NewPlugin symbol not found: %v", err) - } - newPlugin, ok := sym.(func(name string, config map[string]interface{}) (sdk.Plugin, error)) - if !ok { - t.Fatal("NewPlugin has wrong signature") - } - - name := filepath.Base(filepath.Dir(absPath)) - plug, err := newPlugin(name, nil) - if err != nil { - t.Fatalf("NewPlugin: %v", err) - } - - h := &Harness{ - t: t, - plug: plug, - tools: make(map[string]sdk.ToolHandler), - stages: make(map[sdk.Stage][]sdk.StageHandler), - setting: &mockSettings{ - data: make(map[string]interface{}), - defs: make(map[string]sdk.ConfigDef), - }, - } - - h.sdk = sdk.New(name, h.setting, h.regTool, h.regStage, nil) - - if err := plug.Start(h.sdk); err != nil { - t.Fatalf("plugin.Start: %v", err) - } - - return h -} - -func (h *Harness) regTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { - h.mu.Lock() - defer h.mu.Unlock() - h.tools[name] = handler - return nil -} - -func (h *Harness) regStage(stage sdk.Stage, handler sdk.StageHandler) { - h.mu.Lock() - defer h.mu.Unlock() - h.stages[stage] = append(h.stages[stage], handler) -} - -// Plug returns the loaded plugin instance. -func (h *Harness) Plug() sdk.Plugin { return h.plug } - -// SDK returns the mock PluginSDK. -func (h *Harness) SDK() *sdk.PluginSDK { return h.sdk } - -// Settings returns the mock settings store for test assertions. -func (h *Harness) Settings() *mockSettings { return h.setting } - -// ToolNames returns all registered tool names. -func (h *Harness) ToolNames() []string { - h.mu.Lock() - defer h.mu.Unlock() - names := make([]string, 0, len(h.tools)) - for n := range h.tools { - names = append(names, n) - } - return names -} - -// CallTool invokes a registered tool handler with the given arguments. -func (h *Harness) CallTool(name string, args map[string]interface{}) (interface{}, error) { - h.mu.Lock() - handler, ok := h.tools[name] - h.mu.Unlock() - if !ok { - return nil, fmt.Errorf("tool %q not registered", name) - } - return handler(args) -} - -// Close stops the plugin. -func (h *Harness) Close() { - if err := h.plug.Stop(); err != nil { - h.t.Logf("plugin.Stop: %v", err) - } -} - -// AssertToolRegistered fails if the tool is not registered. -func (h *Harness) AssertToolRegistered(name string) { - h.t.Helper() - h.mu.Lock() - defer h.mu.Unlock() - if _, ok := h.tools[name]; !ok { - h.t.Fatalf("expected tool %q to be registered", name) - } -} - -// AssertToolResult checks that calling a tool returns the expected JSON output. -func (h *Harness) AssertToolResult(name string, args map[string]interface{}, expected map[string]interface{}) { - h.t.Helper() - got, err := h.CallTool(name, args) - if err != nil { - h.t.Fatalf("tool %q: %v", name, err) - } - gotJSON, _ := json.Marshal(got) - expJSON, _ := json.Marshal(expected) - if string(gotJSON) != string(expJSON) { - h.t.Fatalf("tool %q:\ngot: %s\nexp: %s", name, gotJSON, expJSON) - } -} - -// mockSettings implements sdk.SettingsAPI for testing. -type mockSettings struct { - mu sync.Mutex - data map[string]interface{} - defs map[string]sdk.ConfigDef -} - -func (m *mockSettings) Get(key string) (interface{}, error) { - m.mu.Lock() - defer m.mu.Unlock() - v, ok := m.data[key] - if !ok { - return nil, fmt.Errorf("key %q not found", key) - } - return v, nil -} - -func (m *mockSettings) Set(key string, value interface{}) error { - m.mu.Lock() - defer m.mu.Unlock() - m.data[key] = value - return nil -} - -func (m *mockSettings) List(prefix string) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - var keys []string - for k := range m.data { - if prefix == "" || strings.HasPrefix(k, prefix) { - keys = append(keys, k) - } - } - return keys, nil -} - -func (m *mockSettings) RegisterDef(def sdk.ConfigDef) { - m.mu.Lock() - defer m.mu.Unlock() - m.defs[def.Key] = def -} - -func (m *mockSettings) Defs(prefix string) []*sdk.ConfigDef { - m.mu.Lock() - defer m.mu.Unlock() - var defs []*sdk.ConfigDef - for _, d := range m.defs { - if prefix == "" || strings.HasPrefix(d.Key, prefix) { - defs = append(defs, &d) - } - } - return defs -} - -func (m *mockSettings) Dump() map[string]interface{} { - m.mu.Lock() - defer m.mu.Unlock() - cp := make(map[string]interface{}) - for k, v := range m.data { - cp[k] = v - } - return cp -} - -func (m *mockSettings) Plugins() []string { return nil } diff --git a/third_party/homeagent-sdk/sdk/API.md b/third_party/homeagent-sdk/sdk/API.md deleted file mode 100644 index e386207..0000000 --- a/third_party/homeagent-sdk/sdk/API.md +++ /dev/null @@ -1,553 +0,0 @@ -# PluginSDK API 参考 - -HomeAgent 内核通过 `*sdk.PluginSDK` 向插件暴露所有能力。插件在 `Start(sdk *PluginSDK)` 中接收此对象。 - -## Plugin 接口 - -所有插件必须实现此接口: - -```go -type Plugin interface { - Name() string // 返回插件名称,与注册名一致 - Start(sdk *PluginSDK) error // 初始化:注册工具、阶段钩子等 - Stop() error // 清理:关连接、停 goroutine -} -``` - -### 入口函数 - -`.so` 动态插件必须导出的工厂函数: - -```go -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) -``` - -- `name`: 插件目录名,也是配置命名空间 -- `config`: 插件依赖注入(预留,当前为空) -- 返回 `Plugin` 实例 - -## PluginSDK 总览 - -``` -PluginSDK -├── 工具注册 -│ └── RegisterTool(name, def, handler) error -├── 阶段钩子 -│ └── RegisterStage(stage, handler) -├── 输入投递 -│ ├── InjectInterruptText(source, channel, text) -│ ├── InjectText(source, channel, text) -│ └── InjectTextNoMemory(source, channel, text) -├── 配置管理 (SettingsAPI) -│ ├── Get(key) / Set(key, value) -│ ├── GetCore(key) / SetCore(key, value) -│ ├── GetPlugin(plugin, key) / SetPlugin(plugin, key, value) -│ ├── List(prefix) / ListCore(prefix) -│ ├── RegisterDef(def) / Defs(prefix) -│ ├── Dump() / Plugins() -├── 记忆访问 -│ ├── Memory() -> MemoryAPI -│ ├── TextMemory() -> TextMemoryAPI -│ ├── DocMemory() -> DocMemoryAPI -├── 知识库 -│ └── Knowledge() -> KnowledgeAPI -└── LLM 管理 - └── LLM() -> LLMAPI -``` - -## 工具注册 - -### RegisterTool - -```go -func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error -``` - -向 LLM 注册一个可调用的工具。`name` 必须全局唯一,建议用插件名前缀避免冲突。 - -### ToolDef - -```go -type ToolDef struct { - Name string `json:"name"` // 工具名 - Plugin string `json:"plugin,omitempty"` // 工具所属插件 - Description string `json:"description"` // LLM 看到的描述 - Parameters map[string]interface{} `json:"parameters"` // JSON Schema -} -``` - -`Parameters` 使用 JSON Schema 格式描述参数。示例: - -```go -sdk.ToolDef{ - Name: "weather_query", - Description: "查询指定城市的天气", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{ - "type": "string", - "description": "城市名称", - }, - }, - "required": []string{"city"}, - }, -} -``` - -### ToolHandler - -```go -type ToolHandler func(args map[string]interface{}) (interface{}, error) -``` - -- `args`: LLM 传入的参数,key 为参数名,value 为对应值 -- 返回值: `interface{}` 会被 JSON 序列化后返回给 LLM -- 返回 `error` 时 LLM 会收到错误信息并可能重试 - -```go -func(args map[string]interface{}) (interface{}, error) { - city, _ := args["city"].(string) - return map[string]interface{}{ - "temp": 25, "weather": "晴", - }, nil -} -``` - -错误结果推荐返回含 `isError` 字段的 map,而非返回 error(避免 LLM 重试): - -```go -return map[string]interface{}{ - "isError": true, - "content": "错误描述", -}, nil -``` - -### ToolCall / ToolResult - -阶段钩子中访问的 LLM 工具调用和结果结构: - -```go -type ToolCall struct { - ID string `json:"id"` // 调用 ID - Name string `json:"name"` // 工具名 - Plugin string `json:"plugin,omitempty"` // 工具所属插件 - Arguments map[string]interface{} `json:"arguments"` // 参数 -} - -type ToolResult struct { - CallID string `json:"call_id"` // 对应 ToolCall.ID - Name string `json:"name"` // 工具名 - Plugin string `json:"plugin,omitempty"` // 工具所属插件 - Success bool `json:"success"` - Result interface{} `json:"result"` // handler 返回值 -} -``` - -## 阶段钩子 - -### RegisterStage - -```go -func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) -``` - -在消息处理管道的指定阶段注入逻辑。同一阶段可注册多个 handler,按注册顺序执行。 - -### Stage - -```go -type Stage string - -const ( - StageOnInput Stage = "on_input" // 消息到达,零处理 - StagePreAction Stage = "pre_action" // LLM 调用前,上下文就绪 - StagePostAction Stage = "post_action" // LLM 返回后 - StageBeforeToolcall Stage = "before_toolcall" // 单个工具执行前 - StageAfterToolcall Stage = "after_toolcall" // 单个工具执行后 - StageBeforeOutput Stage = "before_output" // 最终输出前 - StageAfterOutput Stage = "after_output" // 输出发送后 -) -``` - -### StageHandler - -```go -type StageHandler func(ctx *StageContext) error -``` - -### RegisterStageOwnTools - -```go -func (s *PluginSDK) RegisterStageOwnTools(stage Stage, handler StageHandler) -``` - -仅在 `before_toolcall` / `after_toolcall` 阶段监听**当前插件自己的工具调用**。 - -适用场景: -- QQ 插件只审核 `qq_send_*` 自己的发送工具 -- Web 插件只改写 `web_fetch` 自己的结果 -- Files 插件只审计 `files_write` 自己的写操作 - -其他阶段会退化成普通 `RegisterStage`。 - -### StageContext - -```go -type StageContext struct { - mu sync.RWMutex - RawMessage string // 原始输入文本(on_input 可改写) - UserID string // 用户标识 - GroupID string // 群组标识 - ContextMsgs []map[string]interface{} // 上下文消息列表(pre_action 可注入) - LLMText string // LLM 返回文本(post_action 可改写) - ReasoningContent string // LLM 推理过程文本 - TokenUsage map[string]int // Token 用量 - ToolCalls []ToolCall // LLM 请求的工具调用 - ToolResults []ToolResult // 工具执行结果 - FinalText string // 最终输出文本(before_output 可改写) - Response *string // 设置后短路管道 - Phase Stage // 当前阶段 - Memory []MemItem // 召回的记忆 - NoMemory bool // 是否跳过记忆 - Extra map[string]interface{} // 扩展字段 -} -``` - -**阶段权限矩阵**: - -| 字段 | on_input | pre_action | post_action | before_toolcall | after_toolcall | before_output | after_output | -|------|----------|------------|-------------|-----------------|----------------|---------------|--------------| -| RawMessage | 读写 | - | - | - | - | - | - | -| ContextMsgs | - | 读写 | - | - | - | - | - | -| LLMText | - | - | 读写 | - | - | - | - | -| ToolCalls | - | - | 读写 | 读写 | - | - | - | -| ToolCall.deny | - | - | - | 读写 | - | - | - | -| ToolResults | - | - | - | - | 读写 | - | - | -| FinalText | - | - | - | - | - | 读写 | 只读 | -| Response | 读写 | 读写 | 读写 | 读写 | 读写 | 读写 | - | - -**短路规则**:任意阶段设置 `ctx.Response` 后,管道立即跳到 `after_output`。 - -### 阶段示例 - -```go -// on_input: 拦截黑名单用户 -s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { - if ctx.UserID == "blocked_user" { - resp := "已被限制使用" - ctx.Response = &resp - } - return nil -}) - -// pre_action: 注入额外上下文 -s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error { - ctx.Lock() - ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ - "role": "system", - "content": "当前时间: " + time.Now().Format("15:04"), - }) - ctx.Unlock() - return nil -}) -``` - -### MemItem - -```go -type MemItem struct { - Role string `json:"role"` // system / user / assistant - Content string `json:"content"` // 内容 - Score float64 `json:"score"` // TF-IDF 相关性评分 -} -``` - -## 输入投递 - -插件可以向 Agent 投递输入消息。 - -```go -// 中断投递:可打断当前 LLM 处理 -// - source: 来源标识(插件名) -// - channel: 通道名 -// - text: 消息文本 -func (s *PluginSDK) InjectInterruptText(source, channel, text string) - -// 普通投递:排队等待处理 -func (s *PluginSDK) InjectText(source, channel, text string) - -// 投递但不触发记忆记录 -func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) -``` - -**两种投递方式的区别**: - -| | InjectText | InjectInterruptText | -|---|---|---| -| 处理顺序 | 排队 | 优先 | -| 打断 LLM | 否 | 是(取消当前请求) | -| 适用场景 | 普通消息 | 定时器、重要通知 | - -## 配置管理 - -### SettingsAPI - -插件通过 `s.Settings()` 获取 `SettingsAPI`。每个插件拥有独立的 `config_` SQLite 表。 - -```go -type SettingsAPI interface { - // 自身配置(config_ 表) - Get(key string) (interface{}, error) - Set(key string, value interface{}) error - List(prefix string) ([]string, error) - - // 核心配置(config 表) - GetCore(key string) (interface{}, error) - SetCore(key string, value interface{}) error - ListCore(prefix string) ([]string, error) - - // 其他插件配置(config_ 表) - GetPlugin(plugin, key string) (interface{}, error) - SetPlugin(plugin, key string, value interface{}) error - ListPlugin(plugin, prefix string) ([]string, error) - - // 配置定义(WebUI 显示用) - RegisterDef(def ConfigDef) - Defs(prefix string) []*ConfigDef - - // 全局 - Dump() map[string]interface{} - Plugins() []string -} -``` - -### ConfigDef - -```go -type ConfigDef struct { - Key string `json:"key"` // 配置键名 - Default interface{} `json:"default,omitempty"` // 默认值 - Type string `json:"type"` // 类型:string / number / boolean - DisplayName string `json:"display_name"` // WebUI 显示名称 - Description string `json:"description,omitempty"` // 说明 - Category string `json:"category,omitempty"` // 分组 - Options []string `json:"options,omitempty"` // 选项列表(下拉框) - Min float64 `json:"min,omitempty"` - Max float64 `json:"max,omitempty"` - Step float64 `json:"step,omitempty"` - Required bool `json:"required,omitempty"` - Secret bool `json:"secret,omitempty"` // 敏感信息(输入框掩码) -} -``` - -### 使用示例 - -```go -// 插件启动时注册配置定义 -s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "provider_key", - Type: "string", - DisplayName: "API Key", - Description: "第三方服务 API 密钥", - Secret: true, - Required: true, -}) - -// 运行时读取配置 -apiKey, err := s.Settings().Get("provider_key") - -// 读取核心配置 -dataDir, _ := s.Settings().GetCore("core.daemon.data_dir") - -// 读取其他插件配置 -qqNapcat, _ := s.Settings().GetPlugin("qq", "napcat_url") -``` - -## 记忆访问 - -### MemoryAPI(图记忆) - -存储在 SQLite 图数据库中,entities + relations 表。 - -```go -type MemoryAPI interface { - // 召回:query 为关键词列表,depth 为 BFS 遍历深度 - Recall(query []string, depth int) ([]Entity, []Relation, error) - - // 写入三元组 - Commit(triples []Triple) error - - // 统计:返回实体数、关系数等 - Introspect() (map[string]interface{}, error) - - // 合并实体(同义消歧) - MergeEntities(source, target string) (int, error) - - // 清理:mode 为 "soft"(标记删除)或 "hard"(物理删除) - Purge(criteria map[string]string, mode string) (int, error) -} -``` - -```go -type Entity struct { - Name string `json:"name"` // 实体名称 - Type string `json:"type"` // 类型: Person / Location / Concept ... - MentionCount int `json:"mention_count"` // 提及次数 -} - -type Relation struct { - SourceName string `json:"source_name"` // 主体 - TargetName string `json:"target_name"` // 客体 - RelationType string `json:"relation_type"` // 关系类型: likes / works_at / friend_of ... -} - -type Triple struct { - Subject string `json:"subject"` // 主体实体名 - Relation string `json:"relation"` // 关系 - Object string `json:"object"` // 客体实体名 -} -``` - -### TextMemoryAPI(文本记忆) - -按时间顺序的原始对话日志,JSONL 文件轮转存储。 - -```go -type TextMemoryAPI interface { - Append(evt TextEvent) error -} - -type TextEvent struct { - Role string `json:"role"` // system / user / assistant - Content string `json:"content"` // 内容 - Timestamp int64 `json:"timestamp"` // 时间戳 - Channel string `json:"channel,omitempty"` // 来源通道 -} -``` - -### DocMemoryAPI(文档记忆) - -临时记忆层,JSON 文件 + TF-IDF 向量索引,消费即删。 - -```go -type DocMemoryAPI interface { - // 搜索文档,返回 topK 条 - Query(text string, topK int) []*Doc - - // 插入文档 - Insert(doc *Doc) error - - // 删除文档 - Remove(id string) - - // 统计 - Stats() map[string]interface{} -} - -type Doc struct { - ID string `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - Score float64 `json:"score,omitempty"` -} -``` - -## 知识库 - -### KnowledgeAPI - -文件系统 + TF-IDF 向量检索,独立于记忆系统的索引。 - -```go -type KnowledgeAPI interface { - // 搜索知识条目,返回 topK 匹配 - Search(query string, topK int) ([]*Knowledge, error) - - // 添加知识 - Add(name, content string) error - - // 列出所有知识条目名 - List() ([]string, error) -} - -type Knowledge struct { - Name string `json:"name"` - Content string `json:"content"` -} -``` - -## LLM 管理 - -### LLMAPI - -管理 LLM 提供者源。 - -```go -type LLMAPI interface { - // 列出所有已注册的 LLM 源 - ListSources() []string - - // 切换默认 LLM 源 - SetSource(name string) error - - // 当前使用的 LLM 源 - CurrentSource() string -} -``` - -## IOInjector - -SDK 内部的输入投递接口,`PluginSDK.InjectInterruptText` / `InjectText` / `InjectTextNoMemory` 底层调用。 - -```go -type IOInjector interface { - InjectInterruptText(source, channel, text string) - InjectText(source, channel, text string) - InjectTextNoMemory(source, channel, text string) -} -``` - -内核在插件启动后调用 `sdk.SetIOInjector()` 注入此接口的实际实现。 - -## SDK 辅助类型 - -```go -// 工具注册回调类型 -type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error - -// 阶段注册回调类型 -type StageRegistrar func(stage Stage, handler StageHandler) - -// API 注册回调类型 -type APIRegistrar func(name string) error -``` - -## 插件生命周期 - -``` -内核启动 - │ - ├── plugin.Registry.Load(dir) - │ ├── 扫描 plugins/ 目录 - │ ├── 匹配已注册工厂或动态加载 .so - │ ├── 调用 NewPlugin(name, config) - │ └── 调用 plugin.Start(sdk) ← 插件注册工具/阶段/事件 - │ - ├── 正常运行 - │ ├── LLM 调用 → 路由到注册的工具 - │ └── 消息处理 → 触发注册的阶段钩子 - │ - └── 内核关闭 - └── plugin.Stop() ← 插件清理资源 -``` - -### 内置插件 vs 动态插件 - -| | 内置插件 | 动态 .so 插件 | -|---|---|---| -| 注册方式 | `init()` → `RegisterFactory` | `plugin.Open` 动态加载 | -| 存放位置 | `internal/plugins/` | `/plugins//` | -| 编译 | 编译进内核 | 独立 `go build -buildmode=plugin` | -| SDK 导入 | `gitcode.com/JianFeeeee/HomeAgent/internal/sdk` | `gitcode.com/JianFeeeee/homeagent-sdk/sdk` | -| 热加载 | 需重新编译 | 可运行时加载/卸载 | diff --git a/third_party/homeagent-sdk/sdk/knowledge.go b/third_party/homeagent-sdk/sdk/knowledge.go deleted file mode 100644 index 4c9d5d7..0000000 --- a/third_party/homeagent-sdk/sdk/knowledge.go +++ /dev/null @@ -1,14 +0,0 @@ -package sdk - -// KnowledgeAPI provides access to the knowledge store. -type KnowledgeAPI interface { - Search(query string, topK int) ([]*Knowledge, error) - Add(name, content string) error - List() ([]string, error) -} - -// Knowledge represents a knowledge entry. -type Knowledge struct { - Name string `json:"name"` - Content string `json:"content"` -} diff --git a/third_party/homeagent-sdk/sdk/llm.go b/third_party/homeagent-sdk/sdk/llm.go deleted file mode 100644 index b9da86a..0000000 --- a/third_party/homeagent-sdk/sdk/llm.go +++ /dev/null @@ -1,8 +0,0 @@ -package sdk - -// LLMAPI provides access to the LLM provider manager. -type LLMAPI interface { - ListSources() []string - SetSource(name string) error - CurrentSource() string -} diff --git a/third_party/homeagent-sdk/sdk/memory.go b/third_party/homeagent-sdk/sdk/memory.go deleted file mode 100644 index bb72b94..0000000 --- a/third_party/homeagent-sdk/sdk/memory.go +++ /dev/null @@ -1,60 +0,0 @@ -package sdk - -// MemoryAPI provides access to the graph memory (entity-relation store). -type MemoryAPI interface { - Recall(query []string, depth int) ([]Entity, []Relation, error) - Commit(triples []Triple) error - Introspect() (map[string]interface{}, error) - MergeEntities(source, target string) (int, error) - Purge(criteria map[string]string, mode string) (int, error) -} - -// Entity represents a named entity in the knowledge graph. -type Entity struct { - Name string `json:"name"` - Type string `json:"type"` - MentionCount int `json:"mention_count"` -} - -// Relation represents a relationship between two entities. -type Relation struct { - SourceName string `json:"source_name"` - TargetName string `json:"target_name"` - RelationType string `json:"relation_type"` -} - -// Triple represents a subject-relation-object triple for the knowledge graph. -type Triple struct { - Subject string `json:"subject"` - Relation string `json:"relation"` - Object string `json:"object"` -} - -// TextMemoryAPI provides access to chronological text event storage. -type TextMemoryAPI interface { - Append(evt TextEvent) error -} - -// TextEvent represents a single text memory event. -type TextEvent struct { - Role string `json:"role"` - Content string `json:"content"` - Timestamp int64 `json:"timestamp"` - Channel string `json:"channel,omitempty"` -} - -// DocMemoryAPI provides access to the document vector store. -type DocMemoryAPI interface { - Query(text string, topK int) []*Doc - Insert(doc *Doc) error - Remove(id string) - Stats() map[string]interface{} -} - -// Doc represents a document in the document store. -type Doc struct { - ID string `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - Score float64 `json:"score,omitempty"` -} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go deleted file mode 100644 index 43ceeb4..0000000 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ /dev/null @@ -1,237 +0,0 @@ -package sdk - -import "sync" - -// Plugin is the interface every plugin must implement. -type Plugin interface { - Name() string - Start(sdk *PluginSDK) error - Stop() error -} - -// ToolHandler is a function that handles a tool call. -type ToolHandler func(args map[string]interface{}) (interface{}, error) - -// StageHandler is a function that handles a pipeline stage event. -type StageHandler func(ctx *StageContext) error - -// Stage represents a point in the message processing pipeline. -type Stage string - -const ( - StageOnInput Stage = "on_input" - StagePreAction Stage = "pre_action" - StagePostAction Stage = "post_action" - StageBeforeToolcall Stage = "before_toolcall" - StageAfterToolcall Stage = "after_toolcall" - StageBeforeOutput Stage = "before_output" - StageAfterOutput Stage = "after_output" -) - -// StageContext provides context for stage handlers. -type StageContext struct { - mu sync.RWMutex - RawMessage string - UserID string - GroupID string - ContextMsgs []map[string]interface{} - LLMText string - ReasoningContent string - TokenUsage map[string]int - ToolCalls []ToolCall - ToolResults []ToolResult - FinalText string - Response *string - Phase Stage - Memory []MemItem - NoMemory bool - Extra map[string]interface{} -} - -func (c *StageContext) RLock() { c.mu.RLock() } -func (c *StageContext) RUnlock() { c.mu.RUnlock() } -func (c *StageContext) Lock() { c.mu.Lock() } -func (c *StageContext) Unlock() { c.mu.Unlock() } -func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil } - -// MemItem represents a memory item in stage context. -type MemItem struct { - Role string `json:"role"` - Content string `json:"content"` - Score float64 `json:"score"` -} - -// ToolCall represents a model's request to call a tool. -type ToolCall struct { - ID string `json:"id"` - Name string `json:"name"` - Plugin string `json:"plugin,omitempty"` - Arguments map[string]interface{} `json:"arguments"` -} - -// ToolResult represents the result of a tool call. -type ToolResult struct { - CallID string `json:"call_id"` - Name string `json:"name"` - Plugin string `json:"plugin,omitempty"` - Success bool `json:"success"` - Result interface{} `json:"result"` -} - -// ToolDef describes a tool that the plugin exposes. -type ToolDef struct { - Name string `json:"name"` - Plugin string `json:"plugin,omitempty"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} - -// IOInjector provides methods for injecting input and interrupts into the agent pipeline. -type IOInjector interface { - InjectInterruptText(source, channel, text string) - InjectText(source, channel, text string) - InjectTextNoMemory(source, channel, text string) -} - -// ToolRegistrar registers a tool dynamically. -type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error - -// StageRegistrar registers a stage handler. -type StageRegistrar func(stage Stage, handler StageHandler) - -// APIRegistrar registers a plugin API for external access. -type APIRegistrar func(name string) error - -// PluginSDK is the main API surface provided to plugins at runtime. -// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection. -type PluginSDK struct { - name string - regTool ToolRegistrar - regStage StageRegistrar - regAPI APIRegistrar - io IOInjector - mem MemoryAPI - textMem TextMemoryAPI - docMem DocMemoryAPI - know KnowledgeAPI - llm LLMAPI - sett SettingsAPI -} - -// New creates a PluginSDK with the given dependencies. -func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar) *PluginSDK { - return &PluginSDK{ - name: name, - sett: sett, - regTool: regTool, - regStage: regStage, - regAPI: regAPI, - } -} - -// PluginName returns the name of the plugin. -func (s *PluginSDK) PluginName() string { return s.name } - -// Settings returns the settings API for reading/writing plugin configuration. -func (s *PluginSDK) Settings() SettingsAPI { return s.sett } - -// Memory returns the graph memory API (may be nil if not available). -func (s *PluginSDK) Memory() MemoryAPI { return s.mem } - -// TextMemory returns the text memory API (may be nil if not available). -func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem } - -// DocMemory returns the document memory API (may be nil if not available). -func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem } - -// Knowledge returns the knowledge store API (may be nil if not available). -func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know } - -// LLM returns the LLM provider API (may be nil if not available). -func (s *PluginSDK) LLM() LLMAPI { return s.llm } - -// RegisterTool registers a tool that the LLM can call. -func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error { - if def.Plugin == "" { - def.Plugin = s.name - } - if s.regTool != nil { - return s.regTool(name, def, handler) - } - return nil -} - -// RegisterStage registers a handler for a pipeline stage. -func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) { - if s.regStage != nil { - s.regStage(stage, handler) - } -} - -// RegisterStageOwnTools only listens to this plugin's own tool calls/results in -// before_toolcall / after_toolcall stages. Other stages degrade to RegisterStage. -func (s *PluginSDK) RegisterStageOwnTools(stage Stage, handler StageHandler) { - if s.regStage == nil { - return - } - if stage != StageBeforeToolcall && stage != StageAfterToolcall { - s.regStage(stage, handler) - return - } - s.regStage(stage, func(ctx *StageContext) error { - ctx.RLock() - match := false - switch stage { - case StageBeforeToolcall: - match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name - case StageAfterToolcall: - match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name - } - ctx.RUnlock() - if !match { - return nil - } - return handler(ctx) - }) -} - -// RegisterPluginAPI registers this plugin's API for access by other plugins. -func (s *PluginSDK) RegisterPluginAPI(name string) error { - if s.regAPI != nil { - return s.regAPI(name) - } - return nil -} - -// SetIOInjector sets the IO injector (called by the core at startup). -func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io } - -// SetMemoryAPI sets the memory API (called by the core at startup). -func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem } -func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm } -func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm } -func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn } -func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm } - -// ---- IO Convenience Methods ---- - -// InjectInterruptText injects a text interrupt that can preempt current LLM processing. -func (s *PluginSDK) InjectInterruptText(source, channel, text string) { - if s.io != nil { - s.io.InjectInterruptText(source, channel, text) - } -} - -// InjectText injects a text message into the agent pipeline. -func (s *PluginSDK) InjectText(source, channel, text string) { - if s.io != nil { - s.io.InjectText(source, channel, text) - } -} - -// InjectTextNoMemory injects a text message without generating memory. -func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) { - if s.io != nil { - s.io.InjectTextNoMemory(source, channel, text) - } -} diff --git a/third_party/homeagent-sdk/sdk/settings.go b/third_party/homeagent-sdk/sdk/settings.go deleted file mode 100644 index 61bfdc6..0000000 --- a/third_party/homeagent-sdk/sdk/settings.go +++ /dev/null @@ -1,58 +0,0 @@ -package sdk - -type SettingsAPI interface { - // Get reads the plugin's own config value (config_ table). - Get(key string) (interface{}, error) - - // Set writes a config value to the plugin's own config table. - Set(key string, value interface{}) error - - // List returns all keys matching the given prefix. - List(prefix string) ([]string, error) - - // GetCore reads the core config table. - GetCore(key string) (interface{}, error) - - // SetCore writes to the core config table. - SetCore(key string, value interface{}) error - - // ListCore lists core config keys matching the prefix. - ListCore(prefix string) ([]string, error) - - // GetPlugin reads another plugin's config table. - GetPlugin(plugin, key string) (interface{}, error) - - // SetPlugin writes to another plugin's config table. - SetPlugin(plugin, key string, value interface{}) error - - // ListPlugin lists another plugin's config keys matching the prefix. - ListPlugin(plugin, prefix string) ([]string, error) - - // RegisterDef registers a config definition for UI display. - RegisterDef(def ConfigDef) - - // Defs returns config definitions matching the prefix. - Defs(prefix string) []*ConfigDef - - // Dump returns all config values. - Dump() map[string]interface{} - - // Plugins returns a list of all plugin config namespaces. - Plugins() []string -} - -// ConfigDef describes a configuration field for the WebUI. -type ConfigDef struct { - Key string `json:"key"` - Default interface{} `json:"default,omitempty"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - Description string `json:"description,omitempty"` - Category string `json:"category,omitempty"` - Options []string `json:"options,omitempty"` - Min float64 `json:"min,omitempty"` - Max float64 `json:"max,omitempty"` - Step float64 `json:"step,omitempty"` - Required bool `json:"required,omitempty"` - Secret bool `json:"secret,omitempty"` -}