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,
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 {

View file

@ -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"`

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
}
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
}