diff --git a/README.md b/README.md index ae7f6ee54..2fa71230d 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too | Search Engine | API Key | Free Tier | Link | |--------------|---------|-----------|------| | DuckDuckGo | Not needed | Unlimited | Built-in fallback | +| [Gemini Google Search](https://aistudio.google.com/apikey) | Required | Varies | Gemini with Google Search grounding | | [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized | | [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents | | [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private | diff --git a/config/config.example.json b/config/config.example.json index 2be964ec6..d87f711d0 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -291,6 +291,12 @@ "enabled": false, "max_results": 5 }, + "gemini": { + "enabled": false, + "api_key": "", + "model": "gemini-2.5-flash", + "max_results": 5 + }, "perplexity": { "enabled": false, "api_key": "pplx-xxx", diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index 810d91ef2..d92f58476 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -66,6 +66,32 @@ General settings for fetching and processing webpage content. | `enabled` | bool | true | Enable DuckDuckGo search | | `max_results` | int | 5 | Maximum number of results | +### Gemini Google Search + +Gemini search uses Gemini with Google Search grounding. It returns an AI-synthesized answer with citations from Google Search. + +| Config | Type | Default | Description | +|---------------|--------|----------------------|-----------------------------------| +| `enabled` | bool | false | Enable Gemini Google Search | +| `api_key` | string | - | Google Gemini API key | +| `model` | string | `gemini-2.5-flash` | Gemini model used for search | +| `max_results` | int | 5 | Maximum number of citations | + +```json +{ + "tools": { + "web": { + "gemini": { + "enabled": true, + "api_key": "YOUR_GEMINI_API_KEY", + "model": "gemini-2.5-flash", + "max_results": 5 + } + } + } +} +``` + ### Baidu Search Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries. diff --git a/pkg/config/config.go b/pkg/config/config.go index 49a8028c6..53eaef059 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -847,6 +847,13 @@ type SogouConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SOGOU_MAX_RESULTS"` } +type GeminiSearchConfig struct { + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_ENABLED"` + APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_GEMINI_API_KEY"` + Model string `json:"model" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_MODEL"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_MAX_RESULTS"` +} + type PerplexityConfig struct { Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` @@ -890,16 +897,17 @@ type BaiduSearchConfig struct { } type WebToolsConfig struct { - ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` - Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` - Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` - Sogou SogouConfig `yaml:"-" json:"sogou"` - DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` - Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"` - SearXNG SearXNGConfig `yaml:"-" json:"searxng"` - GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"` - BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"` - Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` + Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` + Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` + Sogou SogouConfig `yaml:"-" json:"sogou"` + DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` + Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"` + Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"` + SearXNG SearXNGConfig `yaml:"-" json:"searxng"` + GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"` + BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"` + Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"` // PreferNative controls whether to use provider-native web search when // the active LLM supports it (e.g. OpenAI web_search_preview). When true, // the client-side web_search tool is hidden to avoid duplicate search surfaces, diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 588184adf..6171f4626 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -341,6 +341,11 @@ func DefaultConfig() *Config { Enabled: false, MaxResults: 5, }, + Gemini: GeminiSearchConfig{ + Enabled: false, + Model: "gemini-2.5-flash", + MaxResults: 5, + }, Perplexity: PerplexityConfig{ Enabled: false, MaxResults: 5, diff --git a/pkg/tools/integration/web.go b/pkg/tools/integration/web.go index 75821e40d..0568ba8dc 100644 --- a/pkg/tools/integration/web.go +++ b/pkg/tools/integration/web.go @@ -472,6 +472,113 @@ type SogouSearchProvider struct { client *http.Client } +type GeminiSearchProvider struct { + apiKey string + model string + proxy string + client *http.Client +} + +func (p *GeminiSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if strings.TrimSpace(p.apiKey) == "" { + return "", errors.New("no API key provided") + } + model := strings.TrimSpace(p.model) + if model == "" { + model = "gemini-2.5-flash" + } + + payload := map[string]any{ + "contents": []map[string]any{{ + "parts": []map[string]string{{"text": query}}, + }}, + "tools": []map[string]any{{"google_search": map[string]any{}}}, + } + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + endpoint := fmt.Sprintf( + "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", + url.PathEscape(model), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Goog-Api-Key", p.apiKey) + req.Header.Set("User-Agent", fmt.Sprintf(userAgentHonest, config.Version)) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gemini search api error (status %d): %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + GroundingMetadata struct { + GroundingChunks []struct { + Web struct { + URI string `json:"uri"` + Title string `json:"title"` + } `json:"web"` + } `json:"groundingChunks"` + } `json:"groundingMetadata"` + } `json:"candidates"` + } + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + if len(searchResp.Candidates) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + candidate := searchResp.Candidates[0] + lines := []string{fmt.Sprintf("Results for: %s (via Gemini Google Search)", query)} + for _, part := range candidate.Content.Parts { + if strings.TrimSpace(part.Text) != "" { + lines = append(lines, strings.TrimSpace(part.Text)) + } + } + citationCount := 0 + for _, chunk := range candidate.GroundingMetadata.GroundingChunks { + if strings.TrimSpace(chunk.Web.URI) == "" { + continue + } + citationCount++ + title := strings.TrimSpace(chunk.Web.Title) + if title == "" { + title = chunk.Web.URI + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", citationCount, title, chunk.Web.URI)) + if citationCount >= count { + break + } + } + return strings.Join(lines, "\n"), nil +} + func (p *SogouSearchProvider) Search( ctx context.Context, query string, @@ -1072,6 +1179,10 @@ type WebSearchToolOptions struct { SogouEnabled bool DuckDuckGoMaxResults int DuckDuckGoEnabled bool + GeminiAPIKey string + GeminiModel string + GeminiMaxResults int + GeminiEnabled bool PerplexityAPIKeys []string PerplexityMaxResults int PerplexityEnabled bool @@ -1104,6 +1215,10 @@ func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { SogouEnabled: cfg.Tools.Web.Sogou.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + GeminiAPIKey: cfg.Tools.Web.Gemini.APIKey.String(), + GeminiModel: cfg.Tools.Web.Gemini.Model, + GeminiMaxResults: cfg.Tools.Web.Gemini.MaxResults, + GeminiEnabled: cfg.Tools.Web.Gemini.Enabled, PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, @@ -1135,6 +1250,7 @@ var ( knownWebSearchProviders = []string{ "sogou", "duckduckgo", + "gemini", "brave", "tavily", "perplexity", @@ -1142,7 +1258,7 @@ var ( "glm_search", "baidu_search", } - autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"} + autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily", "gemini"} autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"} ) @@ -1162,6 +1278,8 @@ func (opts WebSearchToolOptions) providerReady(name string) bool { return opts.SogouEnabled case "duckduckgo": return opts.DuckDuckGoEnabled + case "gemini": + return opts.GeminiEnabled && strings.TrimSpace(opts.GeminiAPIKey) != "" case "brave": return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 case "tavily": @@ -1195,14 +1313,15 @@ func (opts WebSearchToolOptions) resolveProviderName(query string) (string, erro return providerName, nil } + sogouReady := opts.providerReady("sogou") + duckReady := opts.providerReady("duckduckgo") + for _, name := range autoPrimaryWebSearchProviders { if opts.providerReady(name) { return name, nil } } - sogouReady := opts.providerReady("sogou") - duckReady := opts.providerReady("duckduckgo") if sogouReady && duckReady { if prefersDuckDuckGoQuery(query) { return "duckduckgo", nil @@ -1279,6 +1398,24 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in proxy: opts.Proxy, client: client, }, maxResults, nil + case "gemini": + if !opts.providerReady("gemini") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for Gemini: %w", err) + } + maxResults := 10 + if opts.GeminiMaxResults > 0 { + maxResults = min(opts.GeminiMaxResults, 10) + } + return &GeminiSearchProvider{ + apiKey: opts.GeminiAPIKey, + model: opts.GeminiModel, + proxy: opts.Proxy, + client: client, + }, maxResults, nil case "searxng": if !opts.providerReady("searxng") { return nil, 0, nil diff --git a/pkg/tools/integration/web_test.go b/pkg/tools/integration/web_test.go index ba6b3da45..436413085 100644 --- a/pkg/tools/integration/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -1853,6 +1853,191 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T) } } +func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeGemini(t *testing.T) { + opts := WebSearchToolOptions{ + GeminiEnabled: true, + GeminiAPIKey: "google-key", + GeminiModel: "gemini-2.5-flash", + GeminiMaxResults: 5, + BraveEnabled: true, + BraveAPIKeys: []string{"brave-key"}, + BraveMaxResults: 5, + SogouEnabled: true, + SogouMaxResults: 5, + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + } + + name, err := ResolveWebSearchProviderName(opts, "best robotics companies") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if name != "brave" { + t.Fatalf("provider = %q, want brave", name) + } + + name, err = ResolveWebSearchProviderName(opts, "今天上海天气") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if name != "brave" { + t.Fatalf("provider = %q, want brave", name) + } +} + +func TestWebTool_GeminiRequiresAPIKey(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "gemini", + GeminiEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after missing Gemini API key fallback, got %T", tool.provider) + } +} + +func TestGeminiSearchProvider_SearchSuccess(t *testing.T) { + provider := &GeminiSearchProvider{ + apiKey: "google-key", + model: "gemini-2.5-flash", + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", req.Method) + } + if got := req.Header.Get("X-Goog-Api-Key"); got != "google-key" { + t.Fatalf("X-Goog-Api-Key = %q, want google-key", got) + } + if !strings.Contains(req.URL.String(), "/models/gemini-2.5-flash:generateContent") { + t.Fatalf("unexpected URL: %s", req.URL.String()) + } + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + fmt.Fprint(rec, `{ + "candidates": [ + { + "content": { + "parts": [ + {"text": "Answer paragraph one."}, + {"text": "Answer paragraph two."} + ] + }, + "groundingMetadata": { + "groundingChunks": [ + {"web": {"uri": "https://example.com/a", "title": "Result A"}}, + {"web": {"uri": "https://example.com/b", "title": "Result B"}}, + {"web": {"uri": "https://example.com/c", "title": "Result C"}} + ] + } + } + ] +}`) + return rec.Result(), nil + }), + }, + } + + out, err := provider.Search(context.Background(), "robotics", 2, "") + if err != nil { + t.Fatalf("Search() error: %v", err) + } + if !strings.Contains(out, "Results for: robotics (via Gemini Google Search)") { + t.Fatalf("missing header in output: %s", out) + } + if !strings.Contains(out, "Answer paragraph one.") || !strings.Contains(out, "Answer paragraph two.") { + t.Fatalf("missing response text in output: %s", out) + } + if !strings.Contains(out, "1. Result A") || !strings.Contains(out, "2. Result B") { + t.Fatalf("missing citations in output: %s", out) + } + if strings.Contains(out, "Result C") { + t.Fatalf("expected citations to be limited to count=2, got: %s", out) + } +} + +func TestGeminiSearchProvider_SearchIgnoresRange(t *testing.T) { + provider := &GeminiSearchProvider{ + apiKey: "google-key", + model: "gemini-2.5-flash", + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + fmt.Fprint(rec, `{ + "candidates": [ + { + "content": { + "parts": [ + {"text": "Recent robotics result."} + ] + } + } + ] +}`) + return rec.Result(), nil + }), + }, + } + + out, err := provider.Search(context.Background(), "robotics", 2, "d") + if err != nil { + t.Fatalf("Search() error: %v", err) + } + if !strings.Contains(out, "Recent robotics result.") { + t.Fatalf("missing response text in output: %s", out) + } +} + +func TestGeminiSearchProvider_SearchAPIError(t *testing.T) { + provider := &GeminiSearchProvider{ + apiKey: "google-key", + model: "gemini-2.5-flash", + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(rec, `{"error":"quota exceeded"}`) + return rec.Result(), nil + }), + }, + } + + _, err := provider.Search(context.Background(), "robotics", 2, "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "status 429") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGeminiSearchProvider_SearchEmptyCandidates(t *testing.T) { + provider := &GeminiSearchProvider{ + apiKey: "google-key", + model: "gemini-2.5-flash", + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + fmt.Fprint(rec, `{"candidates":[]}`) + return rec.Result(), nil + }), + }, + } + + out, err := provider.Search(context.Background(), "robotics", 2, "") + if err != nil { + t.Fatalf("Search() error: %v", err) + } + if out != "No results for: robotics" { + t.Fatalf("output = %q, want %q", out, "No results for: robotics") + } +} + func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ Provider: "brave", diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go index 193ecd6f5..dc28fe54b 100644 --- a/pkg/tools/integration_facade.go +++ b/pkg/tools/integration_facade.go @@ -28,6 +28,7 @@ type ( TavilySearchProvider = integrationtools.TavilySearchProvider SogouSearchProvider = integrationtools.SogouSearchProvider DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider + GeminiSearchProvider = integrationtools.GeminiSearchProvider PerplexitySearchProvider = integrationtools.PerplexitySearchProvider SearXNGSearchProvider = integrationtools.SearXNGSearchProvider GLMSearchProvider = integrationtools.GLMSearchProvider diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 3476e3c53..c6a9d4254 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -49,6 +49,7 @@ type webSearchProviderConfig struct { BaseURL string `json:"base_url,omitempty"` APIKey string `json:"api_key,omitempty"` APIKeys []string `json:"api_keys,omitempty"` + Model string `json:"model,omitempty"` APIKeySet bool `json:"api_key_set,omitempty"` } @@ -446,6 +447,14 @@ func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Req cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults } + if settings, ok := req.Settings["gemini"]; ok { + cfg.Tools.Web.Gemini.Enabled = settings.Enabled + cfg.Tools.Web.Gemini.MaxResults = settings.MaxResults + cfg.Tools.Web.Gemini.Model = strings.TrimSpace(settings.Model) + if key := strings.TrimSpace(settings.APIKey); key != "" { + cfg.Tools.Web.Gemini.APIKey = *config.NewSecureString(key) + } + } if settings, ok := req.Settings["brave"]; ok { cfg.Tools.Web.Brave.Enabled = settings.Enabled cfg.Tools.Web.Brave.MaxResults = settings.MaxResults @@ -505,7 +514,7 @@ func normalizeWebSearchProvider(provider string) string { switch strings.ToLower(strings.TrimSpace(provider)) { case "", "auto": return "auto" - case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search": + case "sogou", "brave", "tavily", "duckduckgo", "gemini", "perplexity", "searxng", "glm_search", "baidu_search": return strings.ToLower(strings.TrimSpace(provider)) default: return "" @@ -549,6 +558,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { Enabled: cfg.Tools.Web.DuckDuckGo.Enabled, MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, }, + "gemini": { + Enabled: cfg.Tools.Web.Gemini.Enabled, + MaxResults: cfg.Tools.Web.Gemini.MaxResults, + Model: cfg.Tools.Web.Gemini.Model, + APIKeySet: cfg.Tools.Web.Gemini.APIKey.String() != "", + }, "brave": { Enabled: cfg.Tools.Web.Brave.Enabled, MaxResults: cfg.Tools.Web.Brave.MaxResults, @@ -604,6 +619,13 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"), Current: current == "duckduckgo", }, + { + ID: "gemini", + Label: "Gemini (Google Search)", + Configured: picotools.WebSearchProviderReady(opts, "gemini"), + Current: current == "gemini", + RequiresAuth: true, + }, { ID: "brave", Label: "Brave Search", diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go index a09a49fd6..520847bed 100644 --- a/web/backend/api/tools_test.go +++ b/web/backend/api/tools_test.go @@ -540,7 +540,7 @@ func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T) } } -func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) { +func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersInAutoMode(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Web.Provider = "auto" cfg.Tools.Web.Sogou.Enabled = true diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts index a77f3ba80..c7501b188 100644 --- a/web/frontend/src/api/tools.ts +++ b/web/frontend/src/api/tools.ts @@ -30,6 +30,7 @@ export interface WebSearchProviderConfig { max_results: number base_url?: string api_key?: string + model?: string api_key_set?: boolean } diff --git a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx index 52e481136..8dcec359b 100644 --- a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx +++ b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx @@ -30,10 +30,13 @@ const apiKeyProviders = new Set([ "brave", "tavily", "perplexity", + "gemini", "glm_search", "baidu_search", ]) +const modelProviders = new Set(["gemini"]) + export function WebSearchProviderSettings({ providerLabelMap, settings, @@ -226,6 +229,27 @@ function ProviderCard({ /> )} + + {modelProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + model: event.target.value, + })) + } + placeholder={t( + "pages.agent.tools.web_search.model_placeholder", + "Optional model override", + )} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + )} )}