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:
parent
3bb4f4ecc6
commit
efad854d9c
3 changed files with 129 additions and 1 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
118
pkg/tools/web.go
118
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
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue