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
|
package file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
"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 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) Description() string { return "文件操作" }
|
||||||
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
func (p *Plugin) Close() error { return nil }
|
||||||
func (p *Plugin) Close() error { return nil }
|
func (p *Plugin) Enabled() bool { return p.dataDir != "" }
|
||||||
func (p *Plugin) Enabled() bool { return true }
|
|
||||||
|
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 {
|
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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
p := New()
|
||||||
|
if err := p.Init(context.Background(), plugin.PluginConfig{DataDir: t.TempDir()}); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteReadList(t *testing.T) {
|
||||||
|
p := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
out, err := p.Execute(ctx, "file_write", map[string]any{"path": "notes/todo.txt", "content": "买牛奶"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "已写入") {
|
||||||
|
t.Errorf("unexpected write output: %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err = p.Execute(ctx, "file_read", map[string]any{"path": "notes/todo.txt"})
|
||||||
|
if err != nil || out != "买牛奶" {
|
||||||
|
t.Errorf("read = %q, err = %v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err = p.Execute(ctx, "file_list", map[string]any{"path": "notes"})
|
||||||
|
if err != nil || !strings.Contains(out, "todo.txt") {
|
||||||
|
t.Errorf("list = %q, err = %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathEscapeRejected(t *testing.T) {
|
||||||
|
p := setup(t)
|
||||||
|
for _, path := range []string{"../secret", "../../etc/passwd", "/etc/passwd"} {
|
||||||
|
if _, err := p.Execute(context.Background(), "file_read", map[string]any{"path": path}); err == nil {
|
||||||
|
t.Errorf("path %q should be rejected", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadMissingFile(t *testing.T) {
|
||||||
|
p := setup(t)
|
||||||
|
if _, err := p.Execute(context.Background(), "file_read", map[string]any{"path": "nope.txt"}); err == nil {
|
||||||
|
t.Error("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,88 @@
|
|||||||
package http_plugin
|
package http_plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||||
)
|
)
|
||||||
type Plugin struct{}
|
|
||||||
func New() *Plugin { return &Plugin{} }
|
type Plugin struct{ client *http.Client }
|
||||||
func (p *Plugin) Name() string { return "http" }
|
|
||||||
func (p *Plugin) Version() string { return "1.0.0" }
|
func New() *Plugin { return &Plugin{client: &http.Client{Timeout: 15 * time.Second}} }
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return "http" }
|
||||||
|
func (p *Plugin) Version() string { return "1.0.0" }
|
||||||
func (p *Plugin) Description() string { return "HTTP请求" }
|
func (p *Plugin) Description() string { return "HTTP请求" }
|
||||||
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
||||||
func (p *Plugin) Close() error { return nil }
|
func (p *Plugin) Close() error { return nil }
|
||||||
func (p *Plugin) Enabled() bool { return true }
|
func (p *Plugin) Enabled() bool { return true }
|
||||||
|
|
||||||
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
||||||
return []plugin.ToolDefinition{{Name:"http", Description:"发送HTTP请求", Parameters:map[string]any{"url": map[string]string{"type":"string"},"method":map[string]string{"type":"string"}}}}
|
return []plugin.ToolDefinition{{
|
||||||
|
Name: "http_request",
|
||||||
|
Description: "Send an HTTP request and return the status code and body (truncated).",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"url": map[string]string{"type": "string", "description": "Request URL (http/https)"},
|
||||||
|
"method": map[string]string{"type": "string", "description": "GET | POST | PUT | DELETE (default GET)"},
|
||||||
|
"body": map[string]string{"type": "string", "description": "Optional request body"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
func (p *Plugin) Execute(_ context.Context, _ string, _ map[string]any) (string, error) {
|
|
||||||
return "[HTTP] 功能占位", nil
|
func (p *Plugin) Execute(ctx context.Context, _ string, params map[string]any) (string, error) {
|
||||||
|
rawURL, _ := params["url"].(string)
|
||||||
|
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||||
|
return "", fmt.Errorf("url must start with http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(paramStr(params, "method")))
|
||||||
|
switch method {
|
||||||
|
case "":
|
||||||
|
method = "GET"
|
||||||
|
case "GET", "POST", "PUT", "DELETE", "PATCH":
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported method %q", method)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body := paramStr(params, "body"); body != "" {
|
||||||
|
bodyReader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if bodyReader != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
text := string(body)
|
||||||
|
if runes := []rune(text); len(runes) > 4000 {
|
||||||
|
text = string(runes[:4000]) + "\n…(响应过长已截断)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramStr(params map[string]any, key string) string {
|
||||||
|
v, _ := params[key].(string)
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package http_plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
t.Errorf("method = %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Write([]byte("hello"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := New()
|
||||||
|
out, err := p.Execute(context.Background(), "http_request", map[string]any{"url": srv.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "HTTP 200") || !strings.Contains(out, "hello") {
|
||||||
|
t.Errorf("unexpected output: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostWithBody(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if r.Method != "POST" || string(body) != `{"a":1}` {
|
||||||
|
t.Errorf("method=%s body=%s", r.Method, body)
|
||||||
|
}
|
||||||
|
w.WriteHeader(201)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := New()
|
||||||
|
out, err := p.Execute(context.Background(), "http_request", map[string]any{
|
||||||
|
"url": srv.URL, "method": "post", "body": `{"a":1}`,
|
||||||
|
})
|
||||||
|
if err != nil || !strings.Contains(out, "HTTP 201") {
|
||||||
|
t.Errorf("out=%q err=%v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsBadInput(t *testing.T) {
|
||||||
|
p := New()
|
||||||
|
if _, err := p.Execute(context.Background(), "http_request", map[string]any{"url": "ftp://x"}); err == nil {
|
||||||
|
t.Error("bad scheme should fail")
|
||||||
|
}
|
||||||
|
if _, err := p.Execute(context.Background(), "http_request", map[string]any{"url": "http://x", "method": "TRACE"}); err == nil {
|
||||||
|
t.Error("bad method should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,96 @@
|
|||||||
package webfetch
|
package webfetch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||||
)
|
)
|
||||||
type Plugin struct{}
|
|
||||||
func New() *Plugin { return &Plugin{} }
|
type Plugin struct{ client *http.Client }
|
||||||
func (p *Plugin) Name() string { return "web-fetch" }
|
|
||||||
func (p *Plugin) Version() string { return "1.0.0" }
|
func New() *Plugin { return &Plugin{client: &http.Client{Timeout: 20 * time.Second}} }
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return "web-fetch" }
|
||||||
|
func (p *Plugin) Version() string { return "1.0.0" }
|
||||||
func (p *Plugin) Description() string { return "网页抓取" }
|
func (p *Plugin) Description() string { return "网页抓取" }
|
||||||
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
||||||
func (p *Plugin) Close() error { return nil }
|
func (p *Plugin) Close() error { return nil }
|
||||||
func (p *Plugin) Enabled() bool { return true }
|
func (p *Plugin) Enabled() bool { return true }
|
||||||
|
|
||||||
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
||||||
return []plugin.ToolDefinition{{Name:"web_fetch", Description:"获取网页内容", Parameters:map[string]any{"url": map[string]string{"type":"string"}}}}
|
return []plugin.ToolDefinition{{
|
||||||
|
Name: "web_fetch",
|
||||||
|
Description: "Fetch a web page and return its text content (HTML stripped, truncated).",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"url": map[string]string{"type": "string", "description": "Page URL (http/https)"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
func (p *Plugin) Execute(_ context.Context, _ string, _ map[string]any) (string, error) {
|
|
||||||
return "[抓取] 功能占位", nil
|
var (
|
||||||
|
reScript = regexp.MustCompile(`(?is)<(script|style|noscript)[^>]*>.*?</(script|style|noscript)>`)
|
||||||
|
reTags = regexp.MustCompile(`(?s)<[^>]+>`)
|
||||||
|
reBlank = regexp.MustCompile(`\n\s*\n+`)
|
||||||
|
maxBody = 1 << 20 // 1MB
|
||||||
|
maxContent = 4000 // runes returned to the LLM
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Plugin) Execute(ctx context.Context, _ string, params map[string]any) (string, error) {
|
||||||
|
rawURL, _ := params["url"].(string)
|
||||||
|
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||||
|
return "", fmt.Errorf("url must start with http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Cyrene/2.0)")
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("fetch API %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBody)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := htmlToText(string(body))
|
||||||
|
if runes := []rune(text); len(runes) > maxContent {
|
||||||
|
text = string(runes[:maxContent]) + "\n…(内容过长已截断)"
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
return "页面无可用文本内容", nil
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlToText strips scripts/styles/tags and collapses whitespace.
|
||||||
|
func htmlToText(html string) string {
|
||||||
|
html = reScript.ReplaceAllString(html, " ")
|
||||||
|
html = reTags.ReplaceAllString(html, "\n")
|
||||||
|
html = reBlank.ReplaceAllString(html, "\n")
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(html, "\n") {
|
||||||
|
if s := strings.TrimSpace(line); s != "" {
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package webfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchStripsHTML(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`<html><head><style>body{color:red}</style><script>var x=1</script></head>
|
||||||
|
<body><h1>标题</h1><p>正文内容</p></body></html>`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := New()
|
||||||
|
out, err := p.Execute(context.Background(), "web_fetch", map[string]any{"url": srv.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "标题") || !strings.Contains(out, "正文内容") {
|
||||||
|
t.Errorf("missing text: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "color:red") || strings.Contains(out, "var x=1") || strings.Contains(out, "<p>") {
|
||||||
|
t.Errorf("HTML/script/style leaked: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRejectsBadURL(t *testing.T) {
|
||||||
|
p := New()
|
||||||
|
for _, u := range []string{"", "ftp://x", "example.com"} {
|
||||||
|
if _, err := p.Execute(context.Background(), "web_fetch", map[string]any{"url": u}); err == nil {
|
||||||
|
t.Errorf("url %q should be rejected", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchHTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
p := New()
|
||||||
|
if _, err := p.Execute(context.Background(), "web_fetch", map[string]any{"url": srv.URL}); err == nil {
|
||||||
|
t.Error("expected error for 404")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,91 @@
|
|||||||
package websearch
|
package websearch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||||
)
|
)
|
||||||
type Plugin struct{ searxngURL string }
|
|
||||||
func New(url string) *Plugin { return &Plugin{searxngURL: url} }
|
type Plugin struct {
|
||||||
func (p *Plugin) Name() string { return "web-search" }
|
searxngURL string
|
||||||
func (p *Plugin) Version() string { return "1.0.0" }
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(url string) *Plugin {
|
||||||
|
return &Plugin{searxngURL: strings.TrimRight(url, "/"), client: &http.Client{Timeout: 15 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return "web-search" }
|
||||||
|
func (p *Plugin) Version() string { return "1.0.0" }
|
||||||
func (p *Plugin) Description() string { return "网络搜索" }
|
func (p *Plugin) Description() string { return "网络搜索" }
|
||||||
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
||||||
func (p *Plugin) Close() error { return nil }
|
func (p *Plugin) Close() error { return nil }
|
||||||
func (p *Plugin) Enabled() bool { return p.searxngURL != "" }
|
func (p *Plugin) Enabled() bool { return p.searxngURL != "" }
|
||||||
|
|
||||||
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
||||||
return []plugin.ToolDefinition{{Name:"web_search", Description:"互联网搜索", Parameters:map[string]any{"query": map[string]string{"type":"string"}}}}
|
return []plugin.ToolDefinition{{
|
||||||
|
Name: "web_search",
|
||||||
|
Description: "Search the internet via SearXNG. Returns top results with title, URL and snippet.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"query": map[string]string{"type": "string", "description": "Search query"},
|
||||||
|
},
|
||||||
|
"required": []string{"query"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
func (p *Plugin) Execute(_ context.Context, _ string, _ map[string]any) (string, error) {
|
|
||||||
return "[搜索] 引擎未配置", nil
|
type searxResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Execute(ctx context.Context, _ string, params map[string]any) (string, error) {
|
||||||
|
query, _ := params["query"].(string)
|
||||||
|
if query == "" {
|
||||||
|
return "", fmt.Errorf("missing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
reqURL := fmt.Sprintf("%s/search?q=%s&format=json", p.searxngURL, url.QueryEscape(query))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("search request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("search API %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []searxResult `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse results: %w", err)
|
||||||
|
}
|
||||||
|
if len(result.Results) == 0 {
|
||||||
|
return fmt.Sprintf("搜索 %q 没有结果", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "搜索 %q 的结果:\n", query)
|
||||||
|
for i, r := range result.Results {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%d. %s\n %s\n %s\n", i+1, r.Title, r.URL, r.Content)
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package websearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("format") != "json" {
|
||||||
|
t.Error("missing format=json")
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("q") != "昔涟" {
|
||||||
|
t.Errorf("unexpected query: %s", r.URL.Query().Get("q"))
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"results":[
|
||||||
|
{"title":"结果一","url":"https://a.com","content":"摘要一"},
|
||||||
|
{"title":"结果二","url":"https://b.com","content":"摘要二"}
|
||||||
|
]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := New(srv.URL)
|
||||||
|
out, err := p.Execute(context.Background(), "web_search", map[string]any{"query": "昔涟"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "结果一") || !strings.Contains(out, "https://b.com") {
|
||||||
|
t.Errorf("unexpected output: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchNoResults(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := New(srv.URL)
|
||||||
|
out, err := p.Execute(context.Background(), "web_search", map[string]any{"query": "xyz"})
|
||||||
|
if err != nil || !strings.Contains(out, "没有结果") {
|
||||||
|
t.Errorf("out=%q err=%v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchMissingQuery(t *testing.T) {
|
||||||
|
p := New("http://unused")
|
||||||
|
if _, err := p.Execute(context.Background(), "web_search", map[string]any{}); err == nil {
|
||||||
|
t.Error("expected error for missing query")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledWithoutURL(t *testing.T) {
|
||||||
|
if New("").Enabled() {
|
||||||
|
t.Error("plugin should be disabled without SearXNG URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user