feat(plugins): implement web-search, web-fetch, http and file tools
- web-search: real SearXNG /search?format=json query, top-5 formatted results (was a 'not configured' stub) - web-fetch: fetch page, strip scripts/styles/tags, truncate to 4000 runes for the LLM context - http: generic request tool (GET/POST/PUT/DELETE/PATCH) with scheme and method validation, status + truncated body - file: sandboxed read/write/list inside the data directory with path escape rejection; split into file_read/file_write/file_list - all tool schemas converted to proper JSON Schema; httptest/tempdir tests for each plugin
This commit is contained in:
1 parent
d80710d85a
commit
9d040fde0b
8 files changed
+599
-44
No files matched your search
+124
-11
@@ -1,19 +1,132 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||
)
|
||||
type Plugin struct{}
|
||||
|
||||
// Plugin provides sandboxed file read/write inside the configured data directory.
|
||||
type Plugin struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func New() *Plugin { return &Plugin{} }
|
||||
func (p *Plugin) Name() string { return "file" }
|
||||
func (p *Plugin) Version() string { return "1.0.0" }
|
||||
|
||||
func (p *Plugin) Name() string { return "file" }
|
||||
func (p *Plugin) Version() string { return "1.0.0" }
|
||||
func (p *Plugin) Description() string { return "文件操作" }
|
||||
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
||||
func (p *Plugin) Close() error { return nil }
|
||||
func (p *Plugin) Enabled() bool { return true }
|
||||
func (p *Plugin) Close() error { return nil }
|
||||
func (p *Plugin) Enabled() bool { return p.dataDir != "" }
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
|
||||
p.dataDir = cfg.DataDir
|
||||
if p.dataDir == "" {
|
||||
p.dataDir = "data/files"
|
||||
}
|
||||
return os.MkdirAll(p.dataDir, 0o755)
|
||||
}
|
||||
|
||||
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
||||
return []plugin.ToolDefinition{{Name:"file", Description:"读写文件", Parameters:map[string]any{"path": map[string]string{"type":"string"}}}}
|
||||
return []plugin.ToolDefinition{
|
||||
{
|
||||
Name: "file_read",
|
||||
Description: "Read a text file from the data directory.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]string{"type": "string", "description": "Relative path inside the data directory"},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "file_write",
|
||||
Description: "Write text to a file in the data directory (overwrites).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]string{"type": "string", "description": "Relative path inside the data directory"},
|
||||
"content": map[string]string{"type": "string", "description": "Text content to write"},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "file_list",
|
||||
Description: "List files in the data directory (or a subdirectory).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]string{"type": "string", "description": "Relative directory path (default: root)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
func (p *Plugin) Execute(_ context.Context, _ string, _ map[string]any) (string, error) {
|
||||
return "[文件] 功能占位", nil
|
||||
|
||||
func (p *Plugin) Execute(_ context.Context, toolName string, params map[string]any) (string, error) {
|
||||
rel, _ := params["path"].(string)
|
||||
path, err := p.resolve(rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch toolName {
|
||||
case "file_read":
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
text := string(data)
|
||||
if runes := []rune(text); len(runes) > 4000 {
|
||||
text = string(runes[:4000]) + "\n…(文件过长已截断)"
|
||||
}
|
||||
return text, nil
|
||||
|
||||
case "file_write":
|
||||
content, _ := params["content"].(string)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("已写入 %s(%d 字节)", rel, len(content)), nil
|
||||
|
||||
case "file_list":
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return "(空目录)", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
sb.WriteString(name + "\n")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown tool %q", toolName)
|
||||
}
|
||||
|
||||
// resolve maps a relative path into the sandbox and rejects escapes.
|
||||
func (p *Plugin) resolve(rel string) (string, error) {
|
||||
clean := filepath.Clean("/" + rel) // force relative interpretation
|
||||
full := filepath.Join(p.dataDir, clean)
|
||||
root, _ := filepath.Abs(p.dataDir)
|
||||
abs, _ := filepath.Abs(full)
|
||||
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes data directory")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
Reference in new issue
Block a user