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 }