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 <noreply@anthropic.com>
This commit is contained in:
shikihane 2026-02-27 19:47:22 +08:00
parent 3bb4f4ecc6
commit efad854d9c
3 changed files with 129 additions and 1 deletions

View file

@ -117,6 +117,10 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, 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, Proxy: cfg.Tools.Web.Proxy,
}) })
if err != nil { if err != nil {

View file

@ -532,11 +532,19 @@ type PerplexityConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` 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 { type WebToolsConfig struct {
Brave BraveConfig `json:"brave"` Brave BraveConfig `json:"brave"`
Tavily TavilyConfig `json:"tavily"` Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"` Perplexity PerplexityConfig `json:"perplexity"`
Bocha BochaConfig `json:"bocha"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // 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. // 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"` Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`

View file

@ -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 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 { type WebSearchTool struct {
provider SearchProvider provider SearchProvider
maxResults int maxResults int
@ -409,6 +512,10 @@ type WebSearchToolOptions struct {
PerplexityAPIKey string PerplexityAPIKey string
PerplexityMaxResults int PerplexityMaxResults int
PerplexityEnabled bool PerplexityEnabled bool
BochaAPIKey string
BochaBaseURL string
BochaMaxResults int
BochaEnabled bool
Proxy string Proxy string
} }
@ -416,7 +523,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
var provider SearchProvider var provider SearchProvider
maxResults := 5 maxResults := 5
// Priority: Perplexity > Brave > Tavily > DuckDuckGo // Priority: Perplexity > Brave > Tavily > DuckDuckGo > Bocha
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
client, err := createHTTPClient(opts.Proxy, perplexityTimeout) client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil { if err != nil {
@ -458,6 +565,15 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
if opts.DuckDuckGoMaxResults > 0 { if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults 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 { } else {
return nil, nil return nil, nil
} }