From 9d040fde0be5fbf408ff52e6a81562ef52b7d68b Mon Sep 17 00:00:00 2001 From: Aska Date: Fri, 21 Aug 2026 12:57:19 +0800 Subject: [PATCH] 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 --- backend/plugins/file/plugin.go | 135 ++++++++++++++++++++-- backend/plugins/file/plugin_test.go | 57 +++++++++ backend/plugins/http/plugin.go | 91 +++++++++++++-- backend/plugins/http/plugin_test.go | 58 ++++++++++ backend/plugins/web-fetch/plugin.go | 99 ++++++++++++++-- backend/plugins/web-fetch/plugin_test.go | 49 ++++++++ backend/plugins/web-search/plugin.go | 94 +++++++++++++-- backend/plugins/web-search/plugin_test.go | 60 ++++++++++ 8 files changed, 599 insertions(+), 44 deletions(-) create mode 100644 backend/plugins/file/plugin_test.go create mode 100644 backend/plugins/http/plugin_test.go create mode 100644 backend/plugins/web-fetch/plugin_test.go create mode 100644 backend/plugins/web-search/plugin_test.go diff --git a/backend/plugins/file/plugin.go b/backend/plugins/file/plugin.go index b1e897c..65025d9 100644 --- a/backend/plugins/file/plugin.go +++ b/backend/plugins/file/plugin.go @@ -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 } diff --git a/backend/plugins/file/plugin_test.go b/backend/plugins/file/plugin_test.go new file mode 100644 index 0000000..7d64777 --- /dev/null +++ b/backend/plugins/file/plugin_test.go @@ -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") + } +} diff --git a/backend/plugins/http/plugin.go b/backend/plugins/http/plugin.go index 6e8a9c2..1c3af51 100644 --- a/backend/plugins/http/plugin.go +++ b/backend/plugins/http/plugin.go @@ -1,19 +1,88 @@ package http_plugin + import ( - "context" - "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" ) -type Plugin struct{} -func New() *Plugin { return &Plugin{} } -func (p *Plugin) Name() string { return "http" } -func (p *Plugin) Version() string { return "1.0.0" } + +type Plugin struct{ client *http.Client } + +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) 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 true } + 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 } diff --git a/backend/plugins/http/plugin_test.go b/backend/plugins/http/plugin_test.go new file mode 100644 index 0000000..fe617bc --- /dev/null +++ b/backend/plugins/http/plugin_test.go @@ -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") + } +} diff --git a/backend/plugins/web-fetch/plugin.go b/backend/plugins/web-fetch/plugin.go index 0717980..b5a9dfd 100644 --- a/backend/plugins/web-fetch/plugin.go +++ b/backend/plugins/web-fetch/plugin.go @@ -1,19 +1,96 @@ package webfetch + import ( - "context" - "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" ) -type Plugin struct{} -func New() *Plugin { return &Plugin{} } -func (p *Plugin) Name() string { return "web-fetch" } -func (p *Plugin) Version() string { return "1.0.0" } + +type Plugin struct{ client *http.Client } + +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) 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 true } + 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)[^>]*>.*?`) + 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") } diff --git a/backend/plugins/web-fetch/plugin_test.go b/backend/plugins/web-fetch/plugin_test.go new file mode 100644 index 0000000..53adba6 --- /dev/null +++ b/backend/plugins/web-fetch/plugin_test.go @@ -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(` +

标题

正文内容

`)) + })) + 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, "

") { + 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") + } +} diff --git a/backend/plugins/web-search/plugin.go b/backend/plugins/web-search/plugin.go index 4e00ca5..57e72fa 100644 --- a/backend/plugins/web-search/plugin.go +++ b/backend/plugins/web-search/plugin.go @@ -1,19 +1,91 @@ package websearch + import ( - "context" - "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" + "context" + "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} } -func (p *Plugin) Name() string { return "web-search" } -func (p *Plugin) Version() string { return "1.0.0" } + +type Plugin struct { + searxngURL string + 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) Init(_ context.Context, _ plugin.PluginConfig) error { return nil } -func (p *Plugin) Close() error { return nil } -func (p *Plugin) Enabled() bool { return p.searxngURL != "" } +func (p *Plugin) Close() error { return nil } +func (p *Plugin) Enabled() bool { return p.searxngURL != "" } + 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 } diff --git a/backend/plugins/web-search/plugin_test.go b/backend/plugins/web-search/plugin_test.go new file mode 100644 index 0000000..29ed778 --- /dev/null +++ b/backend/plugins/web-search/plugin_test.go @@ -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") + } +}