5c807d76a0
Extracted from Cyrene main repo (backend/pkg/plugins + backend/plugin-manager). Contains SDK interfaces (Plugin/Tool/HostAPI), 13 built-in plugins, ToolRegistry with call log ring buffer, and Plugin Manager REST API service. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
240 lines
6.7 KiB
Go
240 lines
6.7 KiB
Go
package websearch
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||
)
|
||
|
||
type WebSearchPlugin struct {
|
||
sdk.BasePlugin
|
||
client *http.Client
|
||
searxngURL string
|
||
}
|
||
|
||
func NewWebSearchPlugin() *WebSearchPlugin {
|
||
return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}}
|
||
}
|
||
|
||
func NewWebSearchPluginWithURL(searxngURL string) *WebSearchPlugin {
|
||
return &WebSearchPlugin{
|
||
client: &http.Client{Timeout: 10 * time.Second},
|
||
searxngURL: strings.TrimRight(searxngURL, "/"),
|
||
}
|
||
}
|
||
|
||
func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata {
|
||
return sdk.PluginMetadata{
|
||
Name: "web_search", DisplayName: "Web Search", Version: "1.1.0",
|
||
Description: "Search the internet via SearXNG (or DuckDuckGo fallback)",
|
||
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||
}
|
||
}
|
||
|
||
func (p *WebSearchPlugin) Tools() []sdk.Tool {
|
||
return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}}
|
||
}
|
||
|
||
type WebSearchTool struct {
|
||
sdk.BaseTool
|
||
client *http.Client
|
||
searxngURL string
|
||
}
|
||
|
||
// ---- SearXNG response types ----
|
||
type searxngResponse struct {
|
||
Query string `json:"query"`
|
||
NumberOrResults int `json:"number_of_results"`
|
||
Results []searxngResult `json:"results"`
|
||
Answers []string `json:"answers"`
|
||
Suggestions []string `json:"suggestions"`
|
||
}
|
||
|
||
type searxngResult struct {
|
||
Title string `json:"title"`
|
||
URL string `json:"url"`
|
||
Content string `json:"content"`
|
||
Engine string `json:"engine"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
|
||
// ---- DuckDuckGo response types (fallback) ----
|
||
type ddgResponse struct {
|
||
Abstract string `json:"Abstract"`
|
||
AbstractText string `json:"AbstractText"`
|
||
Answer string `json:"Answer"`
|
||
Heading string `json:"Heading"`
|
||
Results []ddgTopic `json:"Results"`
|
||
RelatedTopics []ddgTopic `json:"RelatedTopics"`
|
||
}
|
||
|
||
type ddgTopic struct {
|
||
FirstURL string `json:"FirstURL"`
|
||
Text string `json:"Text"`
|
||
}
|
||
|
||
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
|
||
return sdk.ToolDefinition{
|
||
ID: "web_search", Name: "web_search", DisplayName: "Web Search",
|
||
Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.",
|
||
Category: "network", Complexity: sdk.ComplexitySimple,
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{"query": map[string]interface{}{"type": "string"}},
|
||
"required": []string{"query"},
|
||
},
|
||
}
|
||
}
|
||
|
||
func (t *WebSearchTool) Validate(args map[string]interface{}) error {
|
||
if _, ok := args["query"]; !ok {
|
||
return fmt.Errorf("missing required parameter: query")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||
query, _ := args["query"].(string)
|
||
if query == "" {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: "empty query"}, nil
|
||
}
|
||
|
||
if t.searxngURL != "" {
|
||
return t.searchViaSearXNG(query)
|
||
}
|
||
return t.searchViaDuckDuckGo(query)
|
||
}
|
||
|
||
// China-accessible SearXNG engines (baidu, sogou, 360search, bing all work from China)
|
||
const searxngEngines = "bing,sogou,360search,baidu"
|
||
|
||
func (t *WebSearchTool) searchViaSearXNG(query string) (*sdk.ToolResult, error) {
|
||
apiURL := fmt.Sprintf("%s/search?format=json&engines=%s&q=%s",
|
||
t.searxngURL, searxngEngines, url.QueryEscape(query))
|
||
|
||
resp, err := t.client.Get(apiURL)
|
||
if err != nil {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: false,
|
||
Error: fmt.Sprintf("SearXNG request failed: %v", err)}, nil
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: false,
|
||
Error: fmt.Sprintf("SearXNG returned HTTP %d", resp.StatusCode)}, nil
|
||
}
|
||
|
||
var result searxngResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: false,
|
||
Error: fmt.Sprintf("SearXNG parse error: %v", err)}, nil
|
||
}
|
||
|
||
var out strings.Builder
|
||
out.WriteString(fmt.Sprintf("搜索: %s (共%d条结果)\n\n", query, result.NumberOrResults))
|
||
|
||
// 优先显示答案(如 Wikipedia infobox)
|
||
for _, answer := range result.Answers {
|
||
out.WriteString(fmt.Sprintf("📌 %s\n\n", answer))
|
||
}
|
||
|
||
// 搜索结果(最多5条,按score排序)
|
||
count := 0
|
||
for _, r := range result.Results {
|
||
if count >= 5 {
|
||
break
|
||
}
|
||
if r.Title == "" || r.URL == "" {
|
||
continue
|
||
}
|
||
content := cleanSnippet(r.Content)
|
||
out.WriteString(fmt.Sprintf("%d. **%s**\n %s\n %s\n\n", count+1, r.Title, r.URL, content))
|
||
count++
|
||
}
|
||
|
||
if out.Len() == 0 {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: true,
|
||
Output: fmt.Sprintf("未找到与「%s」相关的结果。", query)}, nil
|
||
}
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
|
||
}
|
||
|
||
func (t *WebSearchTool) searchViaDuckDuckGo(query string) (*sdk.ToolResult, error) {
|
||
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query))
|
||
resp, err := t.client.Get(apiURL)
|
||
if err != nil {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result ddgResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
|
||
}
|
||
|
||
var out strings.Builder
|
||
if result.Answer != "" {
|
||
out.WriteString(fmt.Sprintf("Answer: %s\n\n", result.Answer))
|
||
}
|
||
if result.AbstractText != "" {
|
||
text := result.AbstractText
|
||
if len([]rune(text)) > 500 {
|
||
text = string([]rune(text)[:500]) + "..."
|
||
}
|
||
out.WriteString(fmt.Sprintf("Abstract: %s\n\n", stripHTML(text)))
|
||
}
|
||
topics := result.Results
|
||
if len(topics) == 0 {
|
||
topics = result.RelatedTopics
|
||
}
|
||
count := 0
|
||
for _, topic := range topics {
|
||
if count >= 5 {
|
||
break
|
||
}
|
||
if topic.Text == "" {
|
||
continue
|
||
}
|
||
out.WriteString(fmt.Sprintf("%d. %s (%s)\n", count+1, stripHTML(topic.Text), topic.FirstURL))
|
||
count++
|
||
}
|
||
if out.Len() == 0 {
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: true,
|
||
Output: "No results found for: " + query}, nil
|
||
}
|
||
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
|
||
}
|
||
|
||
func cleanSnippet(s string) string {
|
||
runes := []rune(strings.TrimSpace(s))
|
||
if len(runes) > 200 {
|
||
return string(runes[:200]) + "..."
|
||
}
|
||
return string(runes)
|
||
}
|
||
|
||
func stripHTML(s string) string {
|
||
result := make([]rune, 0, len([]rune(s)))
|
||
inTag := false
|
||
for _, r := range s {
|
||
if r == '<' {
|
||
inTag = true
|
||
continue
|
||
}
|
||
if r == '>' {
|
||
inTag = false
|
||
continue
|
||
}
|
||
if !inTag {
|
||
result = append(result, r)
|
||
}
|
||
}
|
||
return strings.TrimSpace(string(result))
|
||
}
|