mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
feat: converge dynamic plugins onto canonical homeagent-sdk
- Separate built-in plugin interface from external plugin interface - Route dynamic plugin loading through homeagent-sdk/sdk using reflection - Turn internal/sdk into an enhanced wrapper over canonical SDK types - Vendor SDK repo snapshot under third_party/homeagent-sdk for stable builds - Keep internal constructors/adapters for memory, knowledge, llm, settings - Align dynamic QQ loading with canonical SDK chain
This commit is contained in:
84
third_party/homeagent-sdk/example/files/README.md
vendored
Normal file
84
third_party/homeagent-sdk/example/files/README.md
vendored
Normal file
@ -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 模式自动创建父目录
|
||||
482
third_party/homeagent-sdk/example/files/plugin.go
vendored
Normal file
482
third_party/homeagent-sdk/example/files/plugin.go
vendored
Normal file
@ -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
|
||||
}
|
||||
8
third_party/homeagent-sdk/example/files/plugin.json
vendored
Normal file
8
third_party/homeagent-sdk/example/files/plugin.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "files",
|
||||
"version": "1.0.0",
|
||||
"description": "文件系统操作插件,提供文件读写、编辑、目录列表等工具",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["files", "filesystem", "io"]
|
||||
}
|
||||
73
third_party/homeagent-sdk/example/memo/README.md
vendored
Normal file
73
third_party/homeagent-sdk/example/memo/README.md
vendored
Normal file
@ -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,防丢数据
|
||||
273
third_party/homeagent-sdk/example/memo/plugin.go
vendored
Normal file
273
third_party/homeagent-sdk/example/memo/plugin.go
vendored
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
8
third_party/homeagent-sdk/example/memo/plugin.json
vendored
Normal file
8
third_party/homeagent-sdk/example/memo/plugin.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "memo",
|
||||
"version": "1.0.0",
|
||||
"description": "备忘插件,支持创建、完成、列出备忘条目,自动提醒",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["memo", "reminder", "todo"]
|
||||
}
|
||||
16
third_party/homeagent-sdk/example/qq/Makefile
vendored
Normal file
16
third_party/homeagent-sdk/example/qq/Makefile
vendored
Normal file
@ -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
|
||||
107
third_party/homeagent-sdk/example/qq/README.md
vendored
Normal file
107
third_party/homeagent-sdk/example/qq/README.md
vendored
Normal file
@ -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:<port>` | Webhook 监听地址 |
|
||||
| `plugin.qq.napcat_url` | `http://127.0.0.1:<port>` | NapCat HTTP API 基地址 |
|
||||
| `plugin.qq.admin` | 空 | 管理员 QQ 号 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 依赖 NapCat 框架运行,需先启动 NapCat 并配置 webhook 指向本插件地址
|
||||
- 群管理中的破坏性操作(踢人、退群等)在描述中已写明需先请示管理员
|
||||
- `qq_get_message` 返回的消息对象包含完整字段,LLM 可据此判断消息类型和来源
|
||||
1051
third_party/homeagent-sdk/example/qq/plugin.go
vendored
Normal file
1051
third_party/homeagent-sdk/example/qq/plugin.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
third_party/homeagent-sdk/example/qq/plugin.json
vendored
Normal file
8
third_party/homeagent-sdk/example/qq/plugin.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "qq",
|
||||
"version": "1.0.0",
|
||||
"description": "QQ 集成插件,对接 NapCat OneBot 框架,支持消息收发、群管理、好友管理等",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["qq", "napcat", "onebot", "messaging"]
|
||||
}
|
||||
94
third_party/homeagent-sdk/example/web/README.md
vendored
Normal file
94
third_party/homeagent-sdk/example/web/README.md
vendored
Normal file
@ -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 查找 `<a class="result__a"` 标签提取标题和链接,查找 `<a class="result__snippet"` 提取摘要。搜索最多返回 8 条结果。
|
||||
|
||||
由于 DuckDuckGo 在国内被墙,支持通过代理访问(见配置项)。
|
||||
|
||||
### 页面抓取
|
||||
|
||||
`web_fetch` 直接 HTTP GET 目标 URL 并以文本形式返回内容:
|
||||
|
||||
```go
|
||||
// plugin.go:handleFetch
|
||||
resp, err := p.httpClient().Get(rawURL)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
```
|
||||
|
||||
返回内容限制最大 100KB,超过截断并提示。
|
||||
|
||||
### SSRF 防护
|
||||
|
||||
`handleFetch` 检查 URL 是否为内网 IP,防止服务端请求伪造攻击:
|
||||
|
||||
```go
|
||||
// plugin.go:handleFetch — SSRF guard
|
||||
if !strings.HasPrefix(parsedURL, "http") {
|
||||
return errorResult("only http/https allowed"), nil
|
||||
}
|
||||
// 内网 IP 段检查(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
```
|
||||
|
||||
### HTTP 客户端
|
||||
|
||||
`newHTTPClient()` 创建带超时和可选代理的 HTTP 客户端:
|
||||
|
||||
```go
|
||||
// plugin.go:newHTTPClient
|
||||
func (p *Plugin) newHTTPClient() *http.Client {
|
||||
transport := &http.Transport{}
|
||||
if p.proxy != "" {
|
||||
proxyURL, _ := url.Parse(p.proxy)
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(p.timeout) * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
超时和代理均在配置中设置。
|
||||
|
||||
### 配置读取
|
||||
|
||||
```go
|
||||
// plugin.go:Start
|
||||
p.timeout = getSetting[float64](s.Settings(), "timeout", 30)
|
||||
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||
```
|
||||
|
||||
`getSetting` 是泛型辅助函数,支持类型安全的配置读取。
|
||||
|
||||
## 配置项
|
||||
|
||||
| Key | 默认值 | 说明 |
|
||||
|-----|--------|------|
|
||||
| `plugin.web.timeout` | `30` | HTTP 请求超时(秒) |
|
||||
| `plugin.web.proxy` | 空 | HTTP 代理地址,如 `http://<proxy-host>:<proxy-port>` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- DuckDuckGo HTML 格式可能随网站更新变化,如果搜索结果解析失败需调整 `parseSearchResults` 中的 HTML 标记匹配
|
||||
- SSRF 防护默认阻止内网请求,如需访问内网资源需修改 `isInternalIP` 逻辑
|
||||
- 搜索结果依赖 DuckDuckGo 可用性,在国内使用建议配置代理
|
||||
567
third_party/homeagent-sdk/example/web/plugin.go
vendored
Normal file
567
third_party/homeagent-sdk/example/web/plugin.go
vendored
Normal file
@ -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://<proxy-host>:<proxy-port>。为空则不使用代理",
|
||||
Category: "web",
|
||||
})
|
||||
|
||||
t := getSetting[float64](s.Settings(), "timeout", 30)
|
||||
p.timeout = int(t)
|
||||
if p.timeout < 5 {
|
||||
p.timeout = 5
|
||||
}
|
||||
if p.timeout > 120 {
|
||||
p.timeout = 120
|
||||
}
|
||||
|
||||
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"search", sdk.ToolDef{
|
||||
Name: tp + "search",
|
||||
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "Search query"},
|
||||
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}, p.handleSearch)
|
||||
|
||||
s.RegisterTool(tp+"fetch", sdk.ToolDef{
|
||||
Name: tp + "fetch",
|
||||
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
|
||||
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleFetch)
|
||||
|
||||
proxyMsg := ""
|
||||
if p.proxy != "" {
|
||||
proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy)
|
||||
}
|
||||
log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.client.CloseIdleConnections()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── SSRF 保护 ──────────────────────────────────────────────
|
||||
|
||||
var privateCIDRs []*net.IPNet
|
||||
|
||||
func init() {
|
||||
cidrs := []string{
|
||||
"127.0.0.0/8", // loopback
|
||||
"10.0.0.0/8", // private
|
||||
"172.16.0.0/12", // private
|
||||
"192.168.0.0/16", // private
|
||||
"100.64.0.0/10", // carrier-grade NAT
|
||||
"169.254.0.0/16", // link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"fe80::/10", // IPv6 link-local
|
||||
}
|
||||
for _, c := range cidrs {
|
||||
_, n, err := net.ParseCIDR(c)
|
||||
if err == nil {
|
||||
privateCIDRs = append(privateCIDRs, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
for _, n := range privateCIDRs {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Plugin) ssrfCheck(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS lookup failed for %s: %w", host, err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
if isPrivateIP(parsed) {
|
||||
return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── DuckDuckGo 搜索 ────────────────────────────────────────
|
||||
|
||||
type ddgResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
|
||||
form := url.Values{"q": {query}}
|
||||
req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return parseDDGResults(string(body), count), nil
|
||||
}
|
||||
|
||||
func parseDDGResults(html string, count int) []ddgResult {
|
||||
var results []ddgResult
|
||||
|
||||
// Find all result blocks: <div class="result__body"> ... </div>
|
||||
bodyMarker := `result__body"`
|
||||
for i := 0; i < len(html); i++ {
|
||||
idx := strings.Index(html[i:], bodyMarker)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
i += idx
|
||||
|
||||
// Find closing </div>
|
||||
closeIdx := findClosingTag(html, i, "</div>")
|
||||
if closeIdx < 0 {
|
||||
break
|
||||
}
|
||||
block := html[i : closeIdx+6]
|
||||
|
||||
r := parseSingleDDGResult(block)
|
||||
if r.URL != "" {
|
||||
results = append(results, r)
|
||||
if len(results) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
i = closeIdx + 6
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func findClosingTag(s string, start int, tag string) int {
|
||||
depth := 1
|
||||
pos := start
|
||||
for pos < len(s) {
|
||||
nextOpen := strings.Index(s[pos:], `<div`)
|
||||
nextClose := strings.Index(s[pos:], tag)
|
||||
if nextClose < 0 {
|
||||
return -1
|
||||
}
|
||||
if nextOpen >= 0 && nextOpen < nextClose {
|
||||
depth++
|
||||
pos += nextOpen + 4
|
||||
} else {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return pos + nextClose
|
||||
}
|
||||
pos += nextClose + len(tag)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseSingleDDGResult(block string) ddgResult {
|
||||
var r ddgResult
|
||||
|
||||
// Extract URL and title from: <a rel="nofollow" class="result__a" href="URL">TITLE</a>
|
||||
urlMarker := `class="result__a" href="`
|
||||
uIdx := strings.Index(block, urlMarker)
|
||||
if uIdx >= 0 {
|
||||
start := uIdx + len(urlMarker)
|
||||
end := strings.Index(block[start:], `"`)
|
||||
if end >= 0 {
|
||||
r.URL = block[start : start+end]
|
||||
}
|
||||
|
||||
aStart := strings.Index(block[start+end:], `>`)
|
||||
if aStart >= 0 {
|
||||
titleStart := start + end + aStart + 1
|
||||
aEnd := strings.Index(block[titleStart:], `</a>`)
|
||||
if aEnd >= 0 {
|
||||
r.Title = stripTags(block[titleStart : titleStart+aEnd])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet: <a class="result__snippet" ...> ... </a>
|
||||
snippetMarkers := []string{
|
||||
`<a class="result__snippet`,
|
||||
`<div class="result__snippet`,
|
||||
}
|
||||
for _, marker := range snippetMarkers {
|
||||
sIdx := strings.Index(block, marker)
|
||||
if sIdx >= 0 {
|
||||
aStart := strings.Index(block[sIdx:], `>`)
|
||||
if aStart >= 0 {
|
||||
snipStart := sIdx + aStart + 1
|
||||
snipEnd := strings.Index(block[snipStart:], `</a>`)
|
||||
if snipEnd < 0 {
|
||||
snipEnd = strings.Index(block[snipStart:], `</div>`)
|
||||
}
|
||||
if snipEnd >= 0 {
|
||||
r.Snippet = stripTags(block[snipStart : snipStart+snipEnd])
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Web Fetch ──────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
|
||||
rawURL, _ := args["url"].(string)
|
||||
if rawURL == "" {
|
||||
return errorResult("url is required"), nil
|
||||
}
|
||||
|
||||
maxChars := 20000
|
||||
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
||||
maxChars = int(v)
|
||||
}
|
||||
if maxChars > 500000 {
|
||||
maxChars = 500000
|
||||
}
|
||||
|
||||
if err := p.ssrfCheck(rawURL); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return errorResult("invalid URL: " + err.Error()), nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return errorResult("fetch failed: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
|
||||
if err != nil {
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
rawText := string(body)
|
||||
|
||||
// Extract readable content based on content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
var extracted string
|
||||
if strings.Contains(ct, "text/html") {
|
||||
extracted = htmlToText(rawText)
|
||||
} else if strings.Contains(ct, "application/json") {
|
||||
// Pretty-print JSON
|
||||
var v interface{}
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
extracted = string(pretty)
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
|
||||
// Clean up and truncate
|
||||
extracted = strings.TrimSpace(extracted)
|
||||
if len(extracted) > maxChars {
|
||||
extracted = extracted[:maxChars] + "\n\n[Content truncated]"
|
||||
}
|
||||
|
||||
if extracted == "" {
|
||||
extracted = "(empty content)"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": extracted,
|
||||
"details": map[string]interface{}{
|
||||
"url": rawURL,
|
||||
"status": resp.StatusCode,
|
||||
"content_type": ct,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── HTML → 文本 ──────────────────────────────────────────────
|
||||
|
||||
func htmlToText(html string) string {
|
||||
// Remove scripts
|
||||
for {
|
||||
start := strings.Index(strings.ToLower(html), "<script")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], "</script>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
html = html[:start] + html[start+end+9:]
|
||||
}
|
||||
|
||||
// Remove styles
|
||||
for {
|
||||
start := strings.Index(strings.ToLower(html), "<style")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], "</style>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
html = html[:start] + html[start+end+8:]
|
||||
}
|
||||
|
||||
// Replace block-level tags with newlines
|
||||
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
|
||||
html = strings.ReplaceAll(html, tag, "\n")
|
||||
}
|
||||
|
||||
// Remove remaining tags
|
||||
html = stripTags(html)
|
||||
|
||||
// Decode common entities
|
||||
html = strings.ReplaceAll(html, "&", "&")
|
||||
html = strings.ReplaceAll(html, "<", "<")
|
||||
html = strings.ReplaceAll(html, ">", ">")
|
||||
html = strings.ReplaceAll(html, """, "\"")
|
||||
html = strings.ReplaceAll(html, "'", "'")
|
||||
html = strings.ReplaceAll(html, " ", " ")
|
||||
|
||||
// Collapse whitespace
|
||||
lines := strings.Split(html, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Collapse internal whitespace
|
||||
in := []rune(line)
|
||||
var out []rune
|
||||
space := false
|
||||
for _, r := range in {
|
||||
if unicode.IsSpace(r) {
|
||||
if !space {
|
||||
out = append(out, ' ')
|
||||
space = true
|
||||
}
|
||||
} else {
|
||||
out = append(out, r)
|
||||
space = false
|
||||
}
|
||||
}
|
||||
cleaned = append(cleaned, string(out))
|
||||
}
|
||||
|
||||
return strings.Join(cleaned, "\n")
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
var out strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// ── Search 处理 ──────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return errorResult("query is required"), nil
|
||||
}
|
||||
|
||||
count := 5
|
||||
if v, ok := args["count"].(float64); ok && v > 0 {
|
||||
count = int(v)
|
||||
}
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
if count > 20 {
|
||||
count = 20
|
||||
}
|
||||
|
||||
results, err := p.ddgSearch(query, count)
|
||||
if err != nil {
|
||||
return errorResult("search failed: " + err.Error()), nil
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "No results found.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
|
||||
for i, r := range results {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.TrimSpace(sb.String()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return def
|
||||
}
|
||||
val, ok := v.(T)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
8
third_party/homeagent-sdk/example/web/plugin.json
vendored
Normal file
8
third_party/homeagent-sdk/example/web/plugin.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"description": "网络工具插件,提供网页搜索和内容抓取功能",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "search", "http"]
|
||||
}
|
||||
Reference in New Issue
Block a user