From dad62516a8526e3758e737a30b8c6ae4d872ca8d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 10 Jul 2026 13:55:03 +0800 Subject: [PATCH] fix: openclaw translateAndRegister uses full tool name for LLM visibility --- internal/plugins/openclaw/plugin.go | 484 +++++++- third_party/homeagent-sdk/.gitignore | 2 + third_party/homeagent-sdk/README.md | 145 +++ .../homeagent-sdk/example/files/README.md | 84 ++ .../homeagent-sdk/example/files/plugin.go | 482 ++++++++ .../homeagent-sdk/example/files/plugin.json | 8 + .../homeagent-sdk/example/memo/README.md | 73 ++ .../homeagent-sdk/example/memo/plugin.go | 273 +++++ .../homeagent-sdk/example/memo/plugin.json | 8 + third_party/homeagent-sdk/example/qq/Makefile | 16 + .../homeagent-sdk/example/qq/README.md | 107 ++ .../homeagent-sdk/example/qq/plugin.go | 1051 +++++++++++++++++ .../homeagent-sdk/example/qq/plugin.json | 8 + .../homeagent-sdk/example/web/README.md | 94 ++ .../homeagent-sdk/example/web/plugin.go | 567 +++++++++ .../homeagent-sdk/example/web/plugin.json | 8 + third_party/homeagent-sdk/go.mod | 3 + .../homeagent-sdk/hack/plugin-dev/packager.sh | 61 + .../homeagent-sdk/hack/plugin-dev/scaffold.sh | 45 + .../hack/plugin-dev/templates/Makefile.tmpl | 17 + .../hack/plugin-dev/templates/gitignore.tmpl | 2 + .../hack/plugin-dev/templates/plugin.go.tmpl | 54 + .../plugin-dev/templates/plugin.json.tmpl | 10 + .../hack/plugin-dev/testharness/harness.go | 237 ++++ third_party/homeagent-sdk/sdk/API.md | 553 +++++++++ third_party/homeagent-sdk/sdk/knowledge.go | 14 + third_party/homeagent-sdk/sdk/llm.go | 8 + third_party/homeagent-sdk/sdk/memory.go | 60 + third_party/homeagent-sdk/sdk/plugin.go | 237 ++++ third_party/homeagent-sdk/sdk/settings.go | 58 + 30 files changed, 4724 insertions(+), 45 deletions(-) create mode 100644 third_party/homeagent-sdk/.gitignore create mode 100644 third_party/homeagent-sdk/README.md create mode 100644 third_party/homeagent-sdk/example/files/README.md create mode 100644 third_party/homeagent-sdk/example/files/plugin.go create mode 100644 third_party/homeagent-sdk/example/files/plugin.json create mode 100644 third_party/homeagent-sdk/example/memo/README.md create mode 100644 third_party/homeagent-sdk/example/memo/plugin.go create mode 100644 third_party/homeagent-sdk/example/memo/plugin.json create mode 100644 third_party/homeagent-sdk/example/qq/Makefile create mode 100644 third_party/homeagent-sdk/example/qq/README.md create mode 100644 third_party/homeagent-sdk/example/qq/plugin.go create mode 100644 third_party/homeagent-sdk/example/qq/plugin.json create mode 100644 third_party/homeagent-sdk/example/web/README.md create mode 100644 third_party/homeagent-sdk/example/web/plugin.go create mode 100644 third_party/homeagent-sdk/example/web/plugin.json create mode 100644 third_party/homeagent-sdk/go.mod create mode 100755 third_party/homeagent-sdk/hack/plugin-dev/packager.sh create mode 100755 third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh create mode 100644 third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl create mode 100644 third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl create mode 100644 third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl create mode 100644 third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl create mode 100644 third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go create mode 100644 third_party/homeagent-sdk/sdk/API.md create mode 100644 third_party/homeagent-sdk/sdk/knowledge.go create mode 100644 third_party/homeagent-sdk/sdk/llm.go create mode 100644 third_party/homeagent-sdk/sdk/memory.go create mode 100644 third_party/homeagent-sdk/sdk/plugin.go create mode 100644 third_party/homeagent-sdk/sdk/settings.go diff --git a/internal/plugins/openclaw/plugin.go b/internal/plugins/openclaw/plugin.go index 5e75a00..8d28c1e 100644 --- a/internal/plugins/openclaw/plugin.go +++ b/internal/plugins/openclaw/plugin.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "strings" "sync" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -16,6 +17,12 @@ import ( //go:embed simulator/main.js var simulatorSrc string +//go:embed manager/main.js +var managerSrc string + +//go:embed pysimulator/main.py +var pySimulatorSrc string + var SkillsDir string var SimulatorDir string @@ -39,7 +46,10 @@ type Plugin struct { simulatorDir string skills []*plugin.SKILLPlugin sidecars []*sidecarProcess + manager *sidecarProcess + capabilities []string mu sync.Mutex + sdk *sdk.PluginSDK } func New(name, skillsDir string) *Plugin { @@ -57,59 +67,333 @@ func New(name, skillsDir string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { - entries, err := os.ReadDir(p.skillsDir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("read skills dir %s: %w", p.skillsDir, err) + p.sdk = s + + // Launch OC plugin manager first (handles OC-format plugin installation and lifecycle) + os.MkdirAll(p.skillsDir, 0755) + if err := p.launchManager(s); err != nil { + log.Printf("[openclaw] launch manager: %v", err) } - for _, entry := range entries { - skillPath := filepath.Join(p.skillsDir, entry.Name()) - subs, err := os.ReadDir(skillPath) - if err != nil { - continue - } - - hasMainJS := false - hasOCManifest := false - hasOCPackage := false - for _, f := range subs { - switch f.Name() { - case "main.js": - hasMainJS = true - case "openclaw.plugin.json": - hasOCManifest = true - case "package.json": - hasOCPackage = hasOCExtensions(filepath.Join(skillPath, "package.json")) - } - } - - switch { - case hasMainJS: - if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil { - log.Printf("[openclaw] sidecar %s: %v", entry.Name(), err) - } - case hasOCManifest || hasOCPackage: - if err := p.loadOCPlugin(s, skillPath, entry.Name()); err != nil { - log.Printf("[openclaw] ocplugin %s: %v", entry.Name(), err) - } - default: - sk, err := plugin.LoadSKILL(skillPath) + // Load existing plugins from skills dir + if entries, err := os.ReadDir(p.skillsDir); err == nil { + for _, entry := range entries { + skillPath := filepath.Join(p.skillsDir, entry.Name()) + subs, err := os.ReadDir(skillPath) if err != nil { - log.Printf("[openclaw] load skill %s: %v", entry.Name(), err) continue } - p.skills = append(p.skills, sk) - log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version()) + hasMainJS := false + hasMainPy := false + hasOCManifest := false + hasOCPackage := false + for _, f := range subs { + switch f.Name() { + case "main.js": + hasMainJS = true + case "main.py": + hasMainPy = true + case "openclaw.plugin.json": + hasOCManifest = true + case "package.json": + hasOCPackage = hasOCExtensions(filepath.Join(skillPath, "package.json")) + } + } + + switch { + case hasMainJS: + if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil { + log.Printf("[openclaw] sidecar %s: %v", entry.Name(), err) + } + case hasMainPy: + if err := p.loadPySidecar(s, skillPath, entry.Name()); err != nil { + log.Printf("[openclaw] pysidecar %s: %v", entry.Name(), err) + } + case hasOCManifest || hasOCPackage: + log.Printf("[openclaw] ocplugin %s handled by manager", entry.Name()) + default: + sk, err := plugin.LoadSKILL(skillPath) + if err != nil { + log.Printf("[openclaw] load skill %s: %v", entry.Name(), err) + continue + } + p.skills = append(p.skills, sk) + log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version()) + } } } + // Register plugin management tools that talk to the manager (always, even if skills dir is empty) + tp := p.name + "_" + s.RegisterTool(tp+"npm_install", sdk.ToolDef{ + Name: tp + "npm_install", + Description: "安装 OpenClaw 插件管理器中的 npm 插件。通过 npm 安装包,自动检测并加载到模拟器中。安装后立即可用。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "package": map[string]interface{}{"type": "string", "description": "npm 包名或 git 地址 (例如 @openclaw/voice-call, npm:@openclaw/matrix)"}, + }, + "required": []string{"package"}, + }, + }, p.handlePluginInstall) + + s.RegisterTool(tp+"npm_uninstall", sdk.ToolDef{ + Name: tp + "npm_uninstall", + Description: "从 OpenClaw 插件管理器中移除已安装的插件。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "要移除的插件名称"}, + }, + "required": []string{"name"}, + }, + }, p.handlePluginUninstall) + + s.RegisterTool(tp+"list", sdk.ToolDef{ + Name: tp + "list", + Description: "列出插件管理器中所有已安装的 OpenClaw 插件及其工具。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handlePluginList) + return nil } +// ─── Manager ──────────────────────────────────────────────── + +// launchManager 启动 OC 插件管理器(Node.js 进程),用于安装/卸载/加载 OC 格式插件 +func (p *Plugin) launchManager(s *sdk.PluginSDK) error { + managerPath := filepath.Join(p.simulatorDir, "manager.js") + if err := os.MkdirAll(p.simulatorDir, 0755); err != nil { + return fmt.Errorf("create simulator dir: %w", err) + } + if err := os.WriteFile(managerPath, []byte(managerSrc), 0644); err != nil { + return fmt.Errorf("write manager: %w", err) + } + + // Ensure skills dir exists for the manager to scan + os.MkdirAll(p.skillsDir, 0755) + + sp, err := launchProcess("node", managerPath, p.skillsDir, "manager") + if err != nil { + return fmt.Errorf("launch manager: %w", err) + } + if sp == nil { + return nil + } + + // Drain initial registration notifications from already-loaded plugins + for done := false; !done; { + select { + case n := <-sp.NotifyChan(): + p.translateAndRegister(n, sp, s, "manager") + default: + done = true + } + } + go p.notifyLoop(sp, s, "manager") + + p.mu.Lock() + p.manager = sp + p.sidecars = append(p.sidecars, sp) + p.mu.Unlock() + + log.Printf("[openclaw] OC plugin manager started") + return nil +} + +// ─── 管理工具处理 ──────────────────────────────────────────── + +func (p *Plugin) handlePluginInstall(args map[string]interface{}) (interface{}, error) { + pkg, _ := args["package"].(string) + if pkg == "" { + return errorResult("package is required"), nil + } + + p.mu.Lock() + mgr := p.manager + p.mu.Unlock() + + if mgr == nil { + return errorResult("plugin manager not available"), nil + } + + data, err := mgr.call("plugins/install", map[string]interface{}{ + "package": pkg, + }) + if err != nil { + return errorResult(fmt.Sprintf("install failed: %v", err)), nil + } + + var result struct { + Name string `json:"name"` + Tools []string `json:"tools"` + } + if err := json.Unmarshal(data, &result); err != nil { + return map[string]interface{}{ + "content": fmt.Sprintf("Plugin installed. Raw response: %s", string(data)), + }, nil + } + + return map[string]interface{}{ + "content": fmt.Sprintf("已安装插件: %s\n 工具: %s", result.Name, strings.Join(result.Tools, ", ")), + }, nil +} + +func (p *Plugin) handlePluginUninstall(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + if name == "" { + return errorResult("name is required"), nil + } + + p.mu.Lock() + mgr := p.manager + p.mu.Unlock() + + if mgr == nil { + return errorResult("plugin manager not available"), nil + } + + data, err := mgr.call("plugins/uninstall", map[string]interface{}{ + "name": name, + }) + if err != nil { + return errorResult(fmt.Sprintf("uninstall failed: %v", err)), nil + } + + return map[string]interface{}{ + "content": fmt.Sprintf("已卸载插件: %s\n %s", name, string(data)), + }, nil +} + +func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, error) { + p.mu.Lock() + mgr := p.manager + p.mu.Unlock() + + if mgr != nil { + data, err := mgr.call("plugins/list", nil) + if err == nil && data != nil { + var result struct { + Plugins []struct { + Name string `json:"name"` + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"tools"` + } `json:"plugins"` + } + if err := json.Unmarshal(data, &result); err == nil { + var parts []string + parts = append(parts, fmt.Sprintf("Skills dir: %s\n", p.skillsDir)) + + if len(result.Plugins) > 0 { + parts = append(parts, fmt.Sprintf("\nOC 插件 (%d):", len(result.Plugins))) + for _, pl := range result.Plugins { + var names []string + for _, t := range pl.Tools { + names = append(names, t.Name) + } + parts = append(parts, fmt.Sprintf(" %s: %s", pl.Name, strings.Join(names, ", "))) + } + } + + p.mu.Lock() + if len(p.sidecars) > 0 { + sidecarCount := 0 + for _, sp := range p.sidecars { + if sp != p.manager { + sidecarCount++ + } + } + if sidecarCount > 0 { + parts = append(parts, fmt.Sprintf("\nSidecar 插件 (%d):", sidecarCount)) + for _, sp := range p.sidecars { + if sp == p.manager { + continue + } + tools, _ := sp.ListTools() + var names []string + for _, t := range tools { + names = append(names, t.Name) + } + parts = append(parts, fmt.Sprintf(" %s: %s", sp.name, strings.Join(names, ", "))) + } + } + } + if len(p.skills) > 0 { + parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills))) + for _, sk := range p.skills { + parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version())) + } + } + if len(p.capabilities) > 0 { + parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(p.capabilities))) + for _, c := range p.capabilities { + parts = append(parts, fmt.Sprintf(" %s", c)) + } + } + if len(result.Plugins) == 0 && len(p.sidecars) <= 1 && len(p.skills) == 0 { + parts = append(parts, "没有已安装的插件。") + } + p.mu.Unlock() + + return map[string]interface{}{ + "content": strings.Join(parts, "\n"), + }, nil + } + } + } + + // Fallback: list known plugins from Go side + p.mu.Lock() + defer p.mu.Unlock() + + var parts []string + parts = append(parts, fmt.Sprintf("Skills dir: %s\n", p.skillsDir)) + + if len(p.sidecars) > 0 { + parts = append(parts, fmt.Sprintf("\nSidecar/OC 插件 (%d):", len(p.sidecars))) + for _, sp := range p.sidecars { + tools, err := sp.ListTools() + toolList := "" + if err == nil { + var names []string + for _, t := range tools { + names = append(names, t.Name) + } + toolList = strings.Join(names, ", ") + } + parts = append(parts, fmt.Sprintf(" %s: %s", sp.name, toolList)) + } + } + + if len(p.skills) > 0 { + parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills))) + for _, sk := range p.skills { + parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version())) + } + } + + if len(p.capabilities) > 0 { + parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(p.capabilities))) + for _, c := range p.capabilities { + parts = append(parts, fmt.Sprintf(" %s", c)) + } + } + + if len(p.sidecars) == 0 && len(p.skills) == 0 { + parts = append(parts, "没有已安装的插件。") + } + + return map[string]interface{}{ + "content": strings.Join(parts, "\n"), + }, nil +} + func (p *Plugin) loadOCPlugin(s *sdk.PluginSDK, dir, name string) error { simPath := filepath.Join(p.simulatorDir, "main.js") if err := os.MkdirAll(p.simulatorDir, 0755); err != nil { @@ -190,7 +474,7 @@ func (p *Plugin) translateAndRegister(n OCNotification, sp *sidecarProcess, s *s } toolName := fmt.Sprintf("%s_%s", pluginName, d.Name) tDef := sdk.ToolDef{ - Name: d.Name, + Name: toolName, Description: d.Description, Parameters: d.Parameters, } @@ -204,16 +488,117 @@ func (p *Plugin) translateAndRegister(n OCNotification, sp *sidecarProcess, s *s } case "provider": - log.Printf("[openclaw] %s: provider registration (no HomeAgent equivalent, logged only)", pluginName) + var d struct { + Name string `json:"name"` + Description string `json:"description"` + } + json.Unmarshal(params.Data, &d) + cap := fmt.Sprintf("[%s] provides %s provider", pluginName, d.Name) + if d.Description != "" { + cap += ": " + d.Description + } + p.mu.Lock() + p.capabilities = append(p.capabilities, cap) + p.mu.Unlock() case "channel": - log.Printf("[openclaw] %s: channel registration (no HomeAgent equivalent, logged only)", pluginName) + var d struct { + Name string `json:"name"` + Type string `json:"type"` + } + json.Unmarshal(params.Data, &d) + cap := fmt.Sprintf("[%s] registers channel: %s (type: %s)", pluginName, d.Name, d.Type) + p.mu.Lock() + p.capabilities = append(p.capabilities, cap) + p.mu.Unlock() + + case "image_generation_provider": + var d struct{ Name string `json:"name"` } + json.Unmarshal(params.Data, &d) + p.mu.Lock() + p.capabilities = append(p.capabilities, fmt.Sprintf("[%s] image generation provider: %s", pluginName, d.Name)) + p.mu.Unlock() + + case "web_fetch_provider": + var d struct{ Name string `json:"name"` } + json.Unmarshal(params.Data, &d) + p.mu.Lock() + p.capabilities = append(p.capabilities, fmt.Sprintf("[%s] web fetch provider: %s", pluginName, d.Name)) + p.mu.Unlock() + + case "web_search_provider": + var d struct{ Name string `json:"name"` } + json.Unmarshal(params.Data, &d) + p.mu.Lock() + p.capabilities = append(p.capabilities, fmt.Sprintf("[%s] web search provider: %s", pluginName, d.Name)) + p.mu.Unlock() default: - log.Printf("[openclaw] %s: %s capability (no HomeAgent equivalent, logged only)", pluginName, params.Type) + var d struct { + Name string `json:"name"` + Description string `json:"description"` + } + json.Unmarshal(params.Data, &d) + cap := fmt.Sprintf("[%s] capability: %s", pluginName, params.Type) + if d.Name != "" { + cap += " (" + d.Name + ")" + } + p.mu.Lock() + p.capabilities = append(p.capabilities, cap) + p.mu.Unlock() } } +func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error { + simPath := filepath.Join(p.simulatorDir, "pysim.py") + if err := os.MkdirAll(p.simulatorDir, 0755); err != nil { + return fmt.Errorf("create simulator dir: %w", err) + } + if err := os.WriteFile(simPath, []byte(pySimulatorSrc), 0644); err != nil { + return fmt.Errorf("write pysimulator: %w", err) + } + + // 查找可用的 Python 解释器 + pythonBin := "python3" + for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} { + if _, err := os.Stat(candidate); err == nil { + pythonBin = candidate + break + } + } + + sp, err := launchProcess(pythonBin, simPath, dir, name) + if err != nil { + return fmt.Errorf("launch pysimulator: %w", err) + } + if sp == nil { + return nil + } + + // 处理注册通知 (同 OC 插件流程) + for done := false; !done; { + select { + case n := <-sp.NotifyChan(): + p.translateAndRegister(n, sp, s, name) + default: + done = true + } + } + go p.notifyLoop(sp, s, name) + + tools, err := sp.ListTools() + if err != nil { + sp.Close() + return fmt.Errorf("list tools: %w", err) + } + log.Printf("[openclaw] pysidecar %s registered %d tools", name, len(tools)) + + p.mu.Lock() + p.sidecars = append(p.sidecars, sp) + p.mu.Unlock() + return nil +} + func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error { sp, err := launchSidecar(dir, name) if err != nil { @@ -263,9 +648,18 @@ func (p *Plugin) Stop() error { } p.sidecars = nil p.skills = nil + p.manager = nil + p.sdk = nil return nil } +func errorResult(msg string) interface{} { + return map[string]interface{}{ + "isError": true, + "content": msg, + } +} + // hasOCExtensions 检测 package.json 中是否有 openclaw.extensions 或 openclaw.runtimeExtensions func hasOCExtensions(pkgPath string) bool { data, err := os.ReadFile(pkgPath) diff --git a/third_party/homeagent-sdk/.gitignore b/third_party/homeagent-sdk/.gitignore new file mode 100644 index 0000000..0d63db8 --- /dev/null +++ b/third_party/homeagent-sdk/.gitignore @@ -0,0 +1,2 @@ +*.so +*.hmap diff --git a/third_party/homeagent-sdk/README.md b/third_party/homeagent-sdk/README.md new file mode 100644 index 0000000..97b0651 --- /dev/null +++ b/third_party/homeagent-sdk/README.md @@ -0,0 +1,145 @@ +# 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 new file mode 100644 index 0000000..8c9cac6 --- /dev/null +++ b/third_party/homeagent-sdk/example/files/README.md @@ -0,0 +1,84 @@ +# 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 new file mode 100644 index 0000000..53a48d2 --- /dev/null +++ b/third_party/homeagent-sdk/example/files/plugin.go @@ -0,0 +1,482 @@ +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 new file mode 100644 index 0000000..ddefb89 --- /dev/null +++ b/third_party/homeagent-sdk/example/files/plugin.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..31f142e --- /dev/null +++ b/third_party/homeagent-sdk/example/memo/README.md @@ -0,0 +1,73 @@ +# 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 new file mode 100644 index 0000000..07813a7 --- /dev/null +++ b/third_party/homeagent-sdk/example/memo/plugin.go @@ -0,0 +1,273 @@ +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 new file mode 100644 index 0000000..ef2c723 --- /dev/null +++ b/third_party/homeagent-sdk/example/memo/plugin.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..41f7c3d --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/Makefile @@ -0,0 +1,16 @@ +# 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 new file mode 100644 index 0000000..fcc989c --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/README.md @@ -0,0 +1,107 @@ +# 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 new file mode 100644 index 0000000..f787806 --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/plugin.go @@ -0,0 +1,1051 @@ +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 new file mode 100644 index 0000000..f168ea3 --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/plugin.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..c93d3b2 --- /dev/null +++ b/third_party/homeagent-sdk/example/web/README.md @@ -0,0 +1,94 @@ +# 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 new file mode 100644 index 0000000..4e31b73 --- /dev/null +++ b/third_party/homeagent-sdk/example/web/plugin.go @@ -0,0 +1,567 @@ +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 new file mode 100644 index 0000000..cc32bf3 --- /dev/null +++ b/third_party/homeagent-sdk/example/web/plugin.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..a31a79e --- /dev/null +++ b/third_party/homeagent-sdk/go.mod @@ -0,0 +1,3 @@ +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 new file mode 100755 index 0000000..5b9b627 --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/packager.sh @@ -0,0 +1,61 @@ +#!/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 new file mode 100755 index 0000000..83f6d77 --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh @@ -0,0 +1,45 @@ +#!/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 new file mode 100644 index 0000000..f0b857e --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl @@ -0,0 +1,17 @@ +# 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 new file mode 100644 index 0000000..842ab43 --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..a7d1378 --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..de06aed --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl @@ -0,0 +1,10 @@ +{ + "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 new file mode 100644 index 0000000..c06dd81 --- /dev/null +++ b/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go @@ -0,0 +1,237 @@ +// 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 new file mode 100644 index 0000000..e386207 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/API.md @@ -0,0 +1,553 @@ +# 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 new file mode 100644 index 0000000..4c9d5d7 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/knowledge.go @@ -0,0 +1,14 @@ +package sdk + +// KnowledgeAPI provides access to the knowledge store. +type KnowledgeAPI interface { + Search(query string, topK int) ([]*Knowledge, error) + Add(name, content string) error + List() ([]string, error) +} + +// Knowledge represents a knowledge entry. +type Knowledge struct { + Name string `json:"name"` + Content string `json:"content"` +} diff --git a/third_party/homeagent-sdk/sdk/llm.go b/third_party/homeagent-sdk/sdk/llm.go new file mode 100644 index 0000000..b9da86a --- /dev/null +++ b/third_party/homeagent-sdk/sdk/llm.go @@ -0,0 +1,8 @@ +package sdk + +// LLMAPI provides access to the LLM provider manager. +type LLMAPI interface { + ListSources() []string + SetSource(name string) error + CurrentSource() string +} diff --git a/third_party/homeagent-sdk/sdk/memory.go b/third_party/homeagent-sdk/sdk/memory.go new file mode 100644 index 0000000..bb72b94 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/memory.go @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..43ceeb4 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -0,0 +1,237 @@ +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 new file mode 100644 index 0000000..61bfdc6 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/settings.go @@ -0,0 +1,58 @@ +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"` +}