From efad854d9c915b7593445729c9374cf8f59cd2a5 Mon Sep 17 00:00:00 2001 From: shikihane Date: Fri, 27 Feb 2026 19:47:22 +0800 Subject: [PATCH] feat(tools): add Bocha web search provider Add BochaSearchProvider implementing the SearchProvider interface, using the Bocha AI web search API (api.bochaai.com). Appended at the end of the provider priority chain (lowest priority) with configurable API key, base URL, and max results. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 4 ++ pkg/config/config.go | 8 +++ pkg/tools/web.go | 118 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 88afa6119..138758dbd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -117,6 +117,10 @@ func registerSharedTools( PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + BochaAPIKey: cfg.Tools.Web.Bocha.APIKey, + BochaBaseURL: cfg.Tools.Web.Bocha.BaseURL, + BochaMaxResults: cfg.Tools.Web.Bocha.MaxResults, + BochaEnabled: cfg.Tools.Web.Bocha.Enabled, Proxy: cfg.Tools.Web.Proxy, }) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 305ae67e3..d673f82c1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -532,11 +532,19 @@ type PerplexityConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } +type BochaConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BOCHA_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BOCHA_API_KEY"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BOCHA_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BOCHA_MAX_RESULTS"` +} + type WebToolsConfig struct { Brave BraveConfig `json:"brave"` Tavily TavilyConfig `json:"tavily"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` + Bocha BochaConfig `json:"bocha"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 10498126b..5df3508a5 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -391,6 +391,109 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil } +type BochaSearchProvider struct { + apiKey string + baseURL string + proxy string +} + +func (p *BochaSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://api.bochaai.com/v1/web-search" + } + + payload := map[string]any{ + "query": query, + "summary": true, + "count": count, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(payloadBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + client, err := createHTTPClient(p.proxy, 15*time.Second) + if err != nil { + return "", fmt.Errorf("failed to create HTTP client: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("bocha API error (status %d): %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + Code int `json:"code"` + Data struct { + WebPages struct { + Value []struct { + Name string `json:"name"` + URL string `json:"url"` + Snippet string `json:"snippet"` + Summary string `json:"summary"` + SiteName string `json:"siteName"` + DatePublished string `json:"datePublished"` + } `json:"value"` + } `json:"webPages"` + } `json:"data"` + Msg string `json:"msg"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if searchResp.Code != 200 { + return "", fmt.Errorf("bocha API error (code %d): %s", searchResp.Code, searchResp.Msg) + } + + results := searchResp.Data.WebPages.Value + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via Bocha)", query)) + + maxItems := min(len(results), count) + for i := 0; i < maxItems; i++ { + item := results[i] + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Name, item.URL)) + // Prefer summary over snippet (summary is more complete) + desc := item.Summary + if desc == "" { + desc = item.Snippet + } + if desc != "" { + if len(desc) > 500 { + desc = desc[:500] + "..." + } + lines = append(lines, fmt.Sprintf(" %s", desc)) + } + } + + return strings.Join(lines, "\n"), nil +} + type WebSearchTool struct { provider SearchProvider maxResults int @@ -409,6 +512,10 @@ type WebSearchToolOptions struct { PerplexityAPIKey string PerplexityMaxResults int PerplexityEnabled bool + BochaAPIKey string + BochaBaseURL string + BochaMaxResults int + BochaEnabled bool Proxy string } @@ -416,7 +523,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 5 - // Priority: Perplexity > Brave > Tavily > DuckDuckGo + // Priority: Perplexity > Brave > Tavily > DuckDuckGo > Bocha if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { client, err := createHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { @@ -458,6 +565,15 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.DuckDuckGoMaxResults > 0 { maxResults = opts.DuckDuckGoMaxResults } + } else if opts.BochaEnabled && opts.BochaAPIKey != "" { + provider = &BochaSearchProvider{ + apiKey: opts.BochaAPIKey, + baseURL: opts.BochaBaseURL, + proxy: opts.Proxy, + } + if opts.BochaMaxResults > 0 { + maxResults = opts.BochaMaxResults + } } else { return nil, nil }