diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 330c82357..718ea8bfa 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -408,11 +408,19 @@ class AgentLoop: ) ) if self.web_config.enable: + web_search_config_loader = None + if self._provider_snapshot_loader is not None: + def web_search_config_loader(): + from nanobot.config.loader import load_config, resolve_config_env_vars + + return resolve_config_env_vars(load_config()).tools.web.search + self.tools.register( WebSearchTool( config=self.web_config.search, proxy=self.web_config.proxy, user_agent=self.web_config.user_agent, + config_loader=web_search_config_loader, ) ) self.tools.register( diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index aae40ac9c..1b012777e 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -7,7 +7,7 @@ import html import json import os import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable from urllib.parse import quote, urlparse import httpx @@ -91,16 +91,30 @@ class WebSearchTool(Tool): ) def __init__( - self, config: WebSearchConfig | None = None, proxy: str | None = None, user_agent: str | None = None + self, + config: WebSearchConfig | None = None, + proxy: str | None = None, + user_agent: str | None = None, + config_loader: Callable[[], WebSearchConfig] | None = None, ): from nanobot.config.schema import WebSearchConfig self.config = config if config is not None else WebSearchConfig() self.proxy = proxy self.user_agent = user_agent if user_agent is not None else _DEFAULT_USER_AGENT + self._config_loader = config_loader + + def _refresh_config(self) -> None: + if self._config_loader is None: + return + try: + self.config = self._config_loader() + except Exception: + logger.exception("Failed to refresh web search config") def _effective_provider(self) -> str: """Resolve the backend that execute() will actually use.""" + self._refresh_config() provider = self.config.provider.strip().lower() or "brave" if provider == "duckduckgo": return "duckduckgo" @@ -134,6 +148,7 @@ class WebSearchTool(Tool): return self._effective_provider() == "duckduckgo" async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: + self._refresh_config() provider = self.config.provider.strip().lower() or "brave" n = min(max(count or self.config.max_results, 1), 10) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index b121bb4de..ac186b089 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -197,6 +197,20 @@ def _mask_secret_hint(secret: str | None) -> str | None: return f"{secret[:4]}••••{secret[-4:]}" +_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( + {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, + {"name": "brave", "label": "Brave Search", "credential": "api_key"}, + {"name": "tavily", "label": "Tavily", "credential": "api_key"}, + {"name": "searxng", "label": "SearXNG", "credential": "base_url"}, + {"name": "jina", "label": "Jina", "credential": "api_key"}, + {"name": "kagi", "label": "Kagi", "credential": "api_key"}, + {"name": "olostep", "label": "Olostep", "credential": "api_key"}, +) +_WEB_SEARCH_PROVIDER_BY_NAME = { + provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS +} + + def _parse_inbound_payload(raw: str) -> str | None: """Parse a client frame into text; return None for empty or unrecognized content.""" text = raw.strip() @@ -571,6 +585,9 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings/provider/update": return self._handle_settings_provider_update(request) + if got == "/api/settings/web-search/update": + return self._handle_settings_web_search_update(request) + m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -714,6 +731,12 @@ class WebSocketChannel(BaseChannel): "default_api_base": spec.default_api_base or None, } ) + search_config = config.tools.web.search + search_provider = ( + search_config.provider + if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME + else "duckduckgo" + ) return { "agent": { "model": defaults.model, @@ -722,6 +745,12 @@ class WebSocketChannel(BaseChannel): "has_api_key": bool(provider and provider.api_key), }, "providers": providers, + "web_search": { + "provider": search_provider, + "api_key_hint": _mask_secret_hint(search_config.api_key), + "base_url": search_config.base_url or None, + "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS), + }, "runtime": { "config_path": str(get_config_path().expanduser()), }, @@ -821,6 +850,63 @@ class WebSocketChannel(BaseChannel): # API key/base changes are picked up by the next provider snapshot refresh. return _http_json_response(self._settings_payload(requires_restart=False)) + def _handle_settings_web_search_update(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + from nanobot.config.loader import load_config, save_config + + query = _parse_query(request.path) + provider_name = (_query_first(query, "provider") or "").strip().lower() + provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) + if provider_option is None: + return _http_error(400, "unknown web search provider") + + config = load_config() + search_config = config.tools.web.search + previous_provider = search_config.provider + changed = False + + def set_value(attr: str, value: str | None) -> None: + nonlocal changed + if getattr(search_config, attr) != value: + setattr(search_config, attr, value) + changed = True + + if search_config.provider != provider_name: + search_config.provider = provider_name + changed = True + + credential = provider_option["credential"] + if credential == "none": + set_value("api_key", "") + set_value("base_url", "") + elif credential == "base_url": + base_url = _query_first(query, "base_url") + if base_url is None: + base_url = _query_first(query, "baseUrl") + base_url = base_url.strip() if base_url is not None else None + if not base_url and previous_provider == provider_name and search_config.base_url: + base_url = search_config.base_url + if not base_url: + return _http_error(400, "base_url is required") + set_value("base_url", base_url) + set_value("api_key", "") + else: + api_key = _query_first(query, "api_key") + if api_key is None: + api_key = _query_first(query, "apiKey") + api_key = api_key.strip() if api_key is not None else None + if not api_key and previous_provider == provider_name and search_config.api_key: + api_key = search_config.api_key + if not api_key: + return _http_error(400, "api_key is required") + set_value("api_key", api_key) + set_value("base_url", "") + + if changed: + save_config(config) + return _http_json_response(self._settings_payload(requires_restart=False)) + @staticmethod def _is_webui_session_key(key: str) -> bool: """Return True when *key* belongs to the webui's websocket-only surface.""" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 0c22b9d67..de008c36b 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -524,6 +524,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( config = Config() config.agents.defaults.model = "openai/gpt-4o" config.providers.openai.api_key = "secret-key" + config.tools.web.search.provider = "brave" + config.tools.web.search.api_key = "brave-secret" save_config(config, config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) @@ -547,7 +549,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert providers["openai"]["api_key_hint"] == "secr••••-key" assert providers["openrouter"]["configured"] is False assert body["agent"]["has_api_key"] is True + assert body["web_search"]["provider"] == "brave" + assert body["web_search"]["api_key_hint"] == "brav••••cret" + search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]} + assert search_providers["duckduckgo"]["credential"] == "none" + assert search_providers["searxng"]["credential"] == "base_url" assert "secret-key" not in settings.text + assert "brave-secret" not in settings.text provider_updated = await _http_get( "http://127.0.0.1:" @@ -571,11 +579,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert updated.status_code == 200 assert updated.json()["requires_restart"] is False + search_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/web-search/update?provider=searxng" + "&base_url=https%3A%2F%2Fsearch.example.com", + headers={"Authorization": "Bearer tok"}, + ) + assert search_updated.status_code == 200 + search_body = search_updated.json() + assert search_body["requires_restart"] is False + assert search_body["web_search"]["provider"] == "searxng" + assert search_body["web_search"]["api_key_hint"] is None + assert search_body["web_search"]["base_url"] == "https://search.example.com" + saved = load_config(config_path) assert saved.agents.defaults.model == "openrouter/test" assert saved.agents.defaults.provider == "openrouter" assert saved.providers.openrouter.api_key == "sk-or-test" assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1" + assert saved.tools.web.search.provider == "searxng" + assert saved.tools.web.search.api_key == "" + assert saved.tools.web.search.base_url == "https://search.example.com" finally: await channel.stop() await server_task diff --git a/tests/tools/test_search_tools.py b/tests/tools/test_search_tools.py index fac033ac2..4230e236d 100644 --- a/tests/tools/test_search_tools.py +++ b/tests/tools/test_search_tools.py @@ -13,7 +13,24 @@ import pytest from nanobot.agent.loop import AgentLoop from nanobot.agent.subagent import SubagentManager, SubagentStatus from nanobot.agent.tools.search import GlobTool, GrepTool +from nanobot.agent.tools.web import WebSearchTool from nanobot.bus.queue import MessageBus +from nanobot.config.schema import WebSearchConfig + + +@pytest.mark.asyncio +async def test_web_search_tool_refreshes_dynamic_config_loader(monkeypatch) -> None: + tool = WebSearchTool( + config=WebSearchConfig(provider="brave"), + config_loader=lambda: WebSearchConfig(provider="duckduckgo", max_results=3), + ) + + async def fake_duckduckgo(self, query: str, n: int) -> str: + return f"{self.config.provider}:{query}:{n}" + + monkeypatch.setattr(WebSearchTool, "_search_duckduckgo", fake_duckduckgo) + + assert await tool.execute("nanobot") == "duckduckgo:nanobot:3" @pytest.mark.asyncio @@ -185,7 +202,7 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path: # 2. The pagination info is correct assert "pagination: limit=1, offset=1" in result # Count non-empty lines that start with src/ (file paths) - file_lines = [l for l in result.splitlines() if l.startswith("src/")] + file_lines = [line for line in result.splitlines() if line.startswith("src/")] assert len(file_lines) == 1 diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 8b0bc5914..96188e60e 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -39,12 +39,18 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; -import { fetchSettings, updateProviderSettings, updateSettings } from "@/lib/api"; +import { + fetchSettings, + updateProviderSettings, + updateSettings, + updateWebSearchSettings, +} from "@/lib/api"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; -import type { SettingsPayload } from "@/lib/types"; +import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types"; type SettingsSectionKey = "general" | "byok"; +type ByokPaneKey = "llm" | "web-search"; interface SettingsViewProps { theme: "light" | "dark"; @@ -71,12 +77,20 @@ export function SettingsView({ const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [providerSaving, setProviderSaving] = useState(null); + const [webSearchSaving, setWebSearchSaving] = useState(false); const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState("general"); const [expandedProvider, setExpandedProvider] = useState(null); const [providerForms, setProviderForms] = useState>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); + const [webSearchForm, setWebSearchForm] = useState({ + provider: "duckduckgo", + apiKey: "", + baseUrl: "", + }); + const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); + const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [form, setForm] = useState({ model: "", provider: "", @@ -88,6 +102,11 @@ export function SettingsView({ model: payload.agent.model, provider: payload.agent.provider, }); + setWebSearchForm((prev) => ({ + provider: payload.web_search.provider, + apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "", + baseUrl: payload.web_search.base_url ?? "", + })); }, []); useEffect(() => { @@ -186,6 +205,89 @@ export function SettingsView({ } }; + const saveWebSearch = async () => { + if (!settings || webSearchSaving) return; + const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider); + if (!provider) return; + const apiKey = webSearchForm.apiKey?.trim() ?? ""; + const baseUrl = webSearchForm.baseUrl?.trim() ?? ""; + const hasExistingSecret = + provider.credential === "api_key" && + webSearchForm.provider === settings.web_search.provider && + !!settings.web_search.api_key_hint; + + if (provider.credential === "api_key" && !apiKey && !hasExistingSecret) { + setError(t("settings.byok.webSearch.apiKeyRequired")); + return; + } + if (provider.credential === "base_url" && !baseUrl) { + setError(t("settings.byok.webSearch.baseUrlRequired")); + return; + } + + setWebSearchSaving(true); + try { + const update: WebSearchSettingsUpdate = { provider: webSearchForm.provider }; + if (provider.credential === "api_key" && apiKey) update.apiKey = apiKey; + if (provider.credential === "base_url") update.baseUrl = baseUrl; + const payload = await updateWebSearchSettings(token, update); + applyPayload(payload); + setWebSearchForm((prev) => ({ + provider: payload.web_search.provider, + apiKey: "", + baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "", + })); + setWebSearchKeyVisible(false); + setWebSearchKeyEditing(false); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setWebSearchSaving(false); + } + }; + + const resetProviderDraft = useCallback((providerName: string) => { + const provider = settings?.providers.find((item) => item.name === providerName); + if (!provider) return; + setProviderForms((prev) => ({ + ...prev, + [providerName]: { + apiKey: "", + apiBase: provider.api_base ?? provider.default_api_base ?? "", + }, + })); + setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); + setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); + }, [settings]); + + const handleToggleProvider = useCallback((providerName: string) => { + if (expandedProvider) resetProviderDraft(expandedProvider); + setExpandedProvider(expandedProvider === providerName ? null : providerName); + }, [expandedProvider, resetProviderDraft]); + + const resetWebSearchDraft = useCallback(() => { + if (!settings) return; + setWebSearchForm({ + provider: settings.web_search.provider, + apiKey: "", + baseUrl: settings.web_search.base_url ?? "", + }); + setWebSearchKeyVisible(false); + setWebSearchKeyEditing(false); + }, [settings]); + + const handleWebSearchProviderChange = useCallback((provider: string) => { + if (!settings) return; + setWebSearchForm({ + provider, + apiKey: "", + baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "", + }); + setWebSearchKeyVisible(false); + setWebSearchKeyEditing(false); + }, [settings]); + const toggleProviderKeyVisibility = (providerName: string) => { const isVisible = visibleProviderKeys[providerName]; setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible })); @@ -217,7 +319,7 @@ export function SettingsView({ onLogout={onLogout} /> -
+

@@ -257,7 +359,7 @@ export function SettingsView({ saving={saving} onSave={save} onRestart={onRestart} - isRestarting={isRestarting} + isRestarting={isRestarting} onOpenByok={() => setActiveSection("byok")} /> ) : ( @@ -268,9 +370,11 @@ export function SettingsView({ visibleProviderKeys={visibleProviderKeys} editingProviderKeys={editingProviderKeys} providerSaving={providerSaving} - onToggleProvider={(provider) => - setExpandedProvider((current) => (current === provider ? null : provider)) - } + webSearchForm={webSearchForm} + webSearchKeyVisible={webSearchKeyVisible} + webSearchKeyEditing={webSearchKeyEditing} + webSearchSaving={webSearchSaving} + onToggleProvider={handleToggleProvider} onToggleProviderKey={toggleProviderKeyVisibility} onToggleProviderKeyEditing={toggleProviderKeyEditing} onChangeProviderForm={(provider, value) => @@ -284,6 +388,17 @@ export function SettingsView({ })) } onSaveProvider={saveProvider} + onChangeWebSearchForm={setWebSearchForm} + onChangeWebSearchProvider={handleWebSearchProviderChange} + onToggleWebSearchKey={() => setWebSearchKeyVisible((visible) => !visible)} + onToggleWebSearchKeyEditing={() => { + setWebSearchKeyEditing((editing) => !editing); + setWebSearchKeyVisible(false); + setWebSearchForm((prev) => ({ ...prev, apiKey: "" })); + }} + onResetProviderDraft={resetProviderDraft} + onResetWebSearchDraft={resetWebSearchDraft} + onSaveWebSearch={saveWebSearch} /> )}

@@ -533,7 +648,7 @@ function ProviderPicker({ emptyLabel, onChange, }: { - providers: SettingsPayload["providers"]; + providers: Array<{ name: string; label: string }>; value: string; emptyLabel: string; onChange: (provider: string) => void; @@ -584,6 +699,173 @@ function ProviderPicker({ ); } +function WebSearchByokSettings({ + settings, + form, + keyVisible, + keyEditing, + saving, + onChangeForm, + onChangeProvider, + onToggleKey, + onToggleKeyEditing, + onSave, +}: { + settings: SettingsPayload; + form: WebSearchSettingsUpdate; + keyVisible: boolean; + keyEditing: boolean; + saving: boolean; + onChangeForm: Dispatch>; + onChangeProvider: (provider: string) => void; + onToggleKey: () => void; + onToggleKeyEditing: () => void; + onSave: () => void; +}) { + const { t } = useTranslation(); + const selectedProvider = + settings.web_search.providers.find((provider) => provider.name === form.provider) ?? + settings.web_search.providers[0]; + const hasExistingSecret = + selectedProvider?.credential === "api_key" && + form.provider === settings.web_search.provider && + !!settings.web_search.api_key_hint; + const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing); + const apiKey = form.apiKey?.trim() ?? ""; + const baseUrl = form.baseUrl?.trim() ?? ""; + const dirty = + form.provider !== settings.web_search.provider || + apiKey.length > 0 || + baseUrl !== (settings.web_search.base_url ?? ""); + const missingCredential = + selectedProvider?.credential === "api_key" + ? !apiKey && !hasExistingSecret + : selectedProvider?.credential === "base_url" + ? !baseUrl + : false; + + return ( +
+ + + + + + {selectedProvider?.credential === "none" ? ( + + + {t("settings.byok.webSearch.noCredentialRequired")} + + + ) : null} + + {selectedProvider?.credential === "api_key" ? ( + +
+ {showKeyInput ? ( + <> + + onChangeForm((prev) => ({ ...prev, apiKey: event.target.value })) + } + placeholder={ + hasExistingSecret + ? t("settings.byok.apiKeyConfiguredPlaceholder") + : t("settings.byok.apiKeyPlaceholder") + } + className="h-9 rounded-full pr-11 text-[13px]" + /> + + + ) : ( + <> +
+ {settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")} +
+ + + )} +
+
+ ) : null} + + {selectedProvider?.credential === "base_url" ? ( + + + onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value })) + } + placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")} + className="h-9 w-[280px] rounded-full text-[13px]" + /> + + ) : null} + +
+
+ {missingCredential + ? t("settings.byok.webSearch.missingCredential") + : t("settings.byok.webSearch.saveHint")} +
+ +
+
+
+ ); +} + function ByokSettings({ settings, expandedProvider, @@ -591,11 +873,22 @@ function ByokSettings({ visibleProviderKeys, editingProviderKeys, providerSaving, + webSearchForm, + webSearchKeyVisible, + webSearchKeyEditing, + webSearchSaving, onToggleProvider, onToggleProviderKey, onToggleProviderKeyEditing, onChangeProviderForm, onSaveProvider, + onChangeWebSearchForm, + onChangeWebSearchProvider, + onToggleWebSearchKey, + onToggleWebSearchKeyEditing, + onResetProviderDraft, + onResetWebSearchDraft, + onSaveWebSearch, }: { settings: SettingsPayload; expandedProvider: string | null; @@ -603,13 +896,25 @@ function ByokSettings({ visibleProviderKeys: Record; editingProviderKeys: Record; providerSaving: string | null; + webSearchForm: WebSearchSettingsUpdate; + webSearchKeyVisible: boolean; + webSearchKeyEditing: boolean; + webSearchSaving: boolean; onToggleProvider: (provider: string) => void; onToggleProviderKey: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void; onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void; onSaveProvider: (provider: string) => void; + onChangeWebSearchForm: Dispatch>; + onChangeWebSearchProvider: (provider: string) => void; + onToggleWebSearchKey: () => void; + onToggleWebSearchKeyEditing: () => void; + onResetProviderDraft: (provider: string) => void; + onResetWebSearchDraft: () => void; + onSaveWebSearch: () => void; }) { const { t } = useTranslation(); + const [activePane, setActivePane] = useState("llm"); const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); const configuredProviders = settings.providers.filter((provider) => provider.configured); const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured); @@ -751,59 +1056,113 @@ function ByokSettings({
); }; + const panes: Array<{ key: ByokPaneKey; label: string }> = [ + { key: "llm", label: t("settings.byok.tabs.llm") }, + { key: "web-search", label: t("settings.byok.tabs.webSearch") }, + ]; return (

{t("settings.byok.description")}

-
-
- -
- {configuredProviders.length > 0 ? ( -
- {configuredProviders.map(renderProviderRow)} -
- ) : ( - {t("settings.byok.noConfiguredProviders")} - )} -
-
- -
- -
-
- {visibleUnconfiguredProviders.map(renderProviderRow)} -
-
- {hiddenUnconfiguredCount > 0 ? ( - - ) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? ( - - ) : null} -
+ {pane.label} + + ); + })}
+ {activePane === "llm" ? ( +
+
+ +
+ {configuredProviders.length > 0 ? ( +
+ {configuredProviders.map(renderProviderRow)} +
+ ) : ( + {t("settings.byok.noConfiguredProviders")} + )} +
+
+ +
+ +
+
+ {visibleUnconfiguredProviders.map(renderProviderRow)} +
+
+ {hiddenUnconfiguredCount > 0 ? ( + + ) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? ( + + ) : null} +
+
+ ) : ( + + )}
); } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index fbc1ec828..4cf1b6391 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -123,7 +123,28 @@ "hideApiKey": "Hide API key", "noConfiguredProviders": "No configured providers", "configureFirst": "Configure a provider in BYOK first.", - "openByok": "Open BYOK" + "openByok": "Open BYOK", + "tabs": { + "ariaLabel": "BYOK credential type", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "Search provider", + "providerHelp": "Choose the backend used by the web search tool.", + "selectProvider": "Select provider", + "credentials": "Credentials", + "noCredentialRequired": "No key required", + "noCredentialHelp": "DuckDuckGo works without a saved API key.", + "apiKeyHelp": "Stored in config and masked after saving.", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG needs the URL of your own instance.", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "API key is required for this search provider.", + "baseUrlRequired": "Base URL is required for SearXNG.", + "missingCredential": "Add the required credential before saving.", + "saveHint": "Changes apply to new web search requests." + } } }, "chat": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 55814a8b9..e3be10f1d 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -104,7 +104,28 @@ "hideApiKey": "Ocultar API key", "noConfiguredProviders": "No hay proveedores configurados", "configureFirst": "Configura primero un proveedor en BYOK.", - "openByok": "Abrir BYOK" + "openByok": "Abrir BYOK", + "tabs": { + "ariaLabel": "Tipo de credencial BYOK", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "Proveedor de búsqueda", + "providerHelp": "Elige el backend que usará la herramienta web search.", + "selectProvider": "Seleccionar proveedor", + "credentials": "Credenciales", + "noCredentialRequired": "No requiere key", + "noCredentialHelp": "DuckDuckGo funciona sin guardar una API key.", + "apiKeyHelp": "Se guarda en config y se muestra enmascarada después de guardar.", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG necesita la URL de tu propia instancia.", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "Este proveedor de búsqueda requiere una API key.", + "baseUrlRequired": "SearXNG requiere una Base URL.", + "missingCredential": "Añade la credencial requerida antes de guardar.", + "saveHint": "Los cambios se aplican a nuevas solicitudes de web search." + } } }, "chat": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index d544840e9..2cd6888a8 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -104,7 +104,28 @@ "hideApiKey": "Masquer l'API key", "noConfiguredProviders": "Aucun fournisseur configuré", "configureFirst": "Configurez d'abord un fournisseur dans BYOK.", - "openByok": "Ouvrir BYOK" + "openByok": "Ouvrir BYOK", + "tabs": { + "ariaLabel": "Type d'identifiants BYOK", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "Fournisseur de recherche", + "providerHelp": "Choisissez le backend utilisé par l'outil web search.", + "selectProvider": "Choisir un fournisseur", + "credentials": "Identifiants", + "noCredentialRequired": "Aucune key requise", + "noCredentialHelp": "DuckDuckGo fonctionne sans API key enregistrée.", + "apiKeyHelp": "Enregistrée dans la config et masquée après l'enregistrement.", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG nécessite l'URL de votre propre instance.", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "Ce fournisseur de recherche nécessite une API key.", + "baseUrlRequired": "SearXNG nécessite une Base URL.", + "missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.", + "saveHint": "Les changements s'appliquent aux nouvelles requêtes web search." + } } }, "chat": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index dd347d58f..162842219 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -104,7 +104,28 @@ "hideApiKey": "Sembunyikan API key", "noConfiguredProviders": "Belum ada provider terkonfigurasi", "configureFirst": "Konfigurasikan provider di BYOK terlebih dahulu.", - "openByok": "Buka BYOK" + "openByok": "Buka BYOK", + "tabs": { + "ariaLabel": "Jenis kredensial BYOK", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "Search provider", + "providerHelp": "Pilih backend yang digunakan alat web search.", + "selectProvider": "Pilih provider", + "credentials": "Kredensial", + "noCredentialRequired": "Tidak perlu key", + "noCredentialHelp": "DuckDuckGo berfungsi tanpa menyimpan API key.", + "apiKeyHelp": "Disimpan di config dan ditampilkan tersamarkan setelah disimpan.", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG memerlukan URL instance Anda sendiri.", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "Provider pencarian ini memerlukan API key.", + "baseUrlRequired": "SearXNG memerlukan Base URL.", + "missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.", + "saveHint": "Perubahan berlaku untuk permintaan web search baru." + } } }, "chat": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 4cc8a698f..1c39a49f3 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -104,7 +104,28 @@ "hideApiKey": "API key を隠す", "noConfiguredProviders": "設定済み provider がありません", "configureFirst": "先に BYOK で provider を設定してください。", - "openByok": "BYOK を開く" + "openByok": "BYOK を開く", + "tabs": { + "ariaLabel": "BYOK 認証情報タイプ", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "検索 provider", + "providerHelp": "web search ツールで使うバックエンドを選択します。", + "selectProvider": "provider を選択", + "credentials": "認証情報", + "noCredentialRequired": "key は不要", + "noCredentialHelp": "DuckDuckGo は API key を保存せずに使えます。", + "apiKeyHelp": "config に保存され、保存後はマスク表示されます。", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG には自分のインスタンス URL が必要です。", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "この検索 provider には API key が必要です。", + "baseUrlRequired": "SearXNG には Base URL が必要です。", + "missingCredential": "保存する前に必要な認証情報を入力してください。", + "saveHint": "変更は新しい web search リクエストに適用されます。" + } } }, "chat": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index ba08fcc8a..997a253dc 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -104,7 +104,28 @@ "hideApiKey": "API key 숨기기", "noConfiguredProviders": "설정된 provider가 없습니다", "configureFirst": "먼저 BYOK에서 provider를 설정하세요.", - "openByok": "BYOK 열기" + "openByok": "BYOK 열기", + "tabs": { + "ariaLabel": "BYOK 자격 증명 유형", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "검색 provider", + "providerHelp": "web search 도구가 사용할 백엔드를 선택합니다.", + "selectProvider": "provider 선택", + "credentials": "자격 증명", + "noCredentialRequired": "key 필요 없음", + "noCredentialHelp": "DuckDuckGo는 API key를 저장하지 않고 사용할 수 있습니다.", + "apiKeyHelp": "config에 저장되며 저장 후에는 마스킹되어 표시됩니다.", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG에는 자체 인스턴스 URL이 필요합니다.", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "이 검색 provider에는 API key가 필요합니다.", + "baseUrlRequired": "SearXNG에는 Base URL이 필요합니다.", + "missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.", + "saveHint": "변경 사항은 새 web search 요청에 적용됩니다." + } } }, "chat": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 6bc3797f8..3612145f4 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -104,7 +104,28 @@ "hideApiKey": "Ẩn API key", "noConfiguredProviders": "Chưa có provider đã cấu hình", "configureFirst": "Hãy cấu hình provider trong BYOK trước.", - "openByok": "Mở BYOK" + "openByok": "Mở BYOK", + "tabs": { + "ariaLabel": "Loại thông tin xác thực BYOK", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "Search provider", + "providerHelp": "Chọn backend mà công cụ web search sẽ dùng.", + "selectProvider": "Chọn provider", + "credentials": "Thông tin xác thực", + "noCredentialRequired": "Không cần key", + "noCredentialHelp": "DuckDuckGo hoạt động mà không cần lưu API key.", + "apiKeyHelp": "Được lưu trong config và chỉ hiện dạng che sau khi lưu.", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG cần URL instance của bạn.", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "Provider tìm kiếm này cần API key.", + "baseUrlRequired": "SearXNG cần Base URL.", + "missingCredential": "Thêm thông tin bắt buộc trước khi lưu.", + "saveHint": "Thay đổi áp dụng cho các yêu cầu web search mới." + } } }, "chat": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index d7ccfde19..fed932f29 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -111,7 +111,28 @@ "hideApiKey": "隐藏 API key", "noConfiguredProviders": "没有已配置的 provider", "configureFirst": "请先在 BYOK 里配置 provider。", - "openByok": "打开 BYOK" + "openByok": "打开 BYOK", + "tabs": { + "ariaLabel": "BYOK 凭证类型", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "搜索 provider", + "providerHelp": "选择 web search 工具使用的后端。", + "selectProvider": "选择 provider", + "credentials": "凭证", + "noCredentialRequired": "无需 key", + "noCredentialHelp": "DuckDuckGo 不需要保存 API key。", + "apiKeyHelp": "保存到 config 后只显示掩码提示。", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG 需要你自己的实例地址。", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "这个搜索 provider 需要 API key。", + "baseUrlRequired": "SearXNG 需要 Base URL。", + "missingCredential": "填写所需凭证后才能保存。", + "saveHint": "改动会应用到新的 web search 请求。" + } } }, "chat": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index d498924de..46ba8c6be 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -104,7 +104,28 @@ "hideApiKey": "隱藏 API key", "noConfiguredProviders": "沒有已設定的 provider", "configureFirst": "請先在 BYOK 設定 provider。", - "openByok": "開啟 BYOK" + "openByok": "開啟 BYOK", + "tabs": { + "ariaLabel": "BYOK 憑證類型", + "llm": "LLM", + "webSearch": "Web Search" + }, + "webSearch": { + "provider": "搜尋 provider", + "providerHelp": "選擇 web search 工具使用的後端。", + "selectProvider": "選擇 provider", + "credentials": "憑證", + "noCredentialRequired": "不需要 key", + "noCredentialHelp": "DuckDuckGo 不需要儲存 API key。", + "apiKeyHelp": "儲存到 config 後只顯示遮罩提示。", + "baseUrl": "Base URL", + "baseUrlHelp": "SearXNG 需要你自己的實例網址。", + "baseUrlPlaceholder": "https://search.example.com", + "apiKeyRequired": "此搜尋 provider 需要 API key。", + "baseUrlRequired": "SearXNG 需要 Base URL。", + "missingCredential": "填寫必要憑證後才能儲存。", + "saveHint": "變更會套用到新的 web search 請求。" + } } }, "chat": { diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 4658c4673..23a8c2a67 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -4,6 +4,7 @@ import type { SettingsPayload, SettingsUpdate, SlashCommand, + WebSearchSettingsUpdate, } from "./types"; export class ApiError extends Error { @@ -168,3 +169,18 @@ export async function updateProviderSettings( token, ); } + +export async function updateWebSearchSettings( + token: string, + update: WebSearchSettingsUpdate, + base: string = "", +): Promise { + const query = new URLSearchParams(); + query.set("provider", update.provider); + if (update.apiKey !== undefined) query.set("api_key", update.apiKey); + if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl); + return request( + `${base}/api/settings/web-search/update?${query}`, + token, + ); +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 9d5871f5d..d3489b8de 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -82,6 +82,16 @@ export interface SettingsPayload { api_base?: string | null; default_api_base?: string | null; }>; + web_search: { + provider: string; + api_key_hint?: string | null; + base_url?: string | null; + providers: Array<{ + name: string; + label: string; + credential: "none" | "api_key" | "base_url"; + }>; + }; runtime: { config_path: string; }; @@ -99,6 +109,12 @@ export interface ProviderSettingsUpdate { apiBase?: string; } +export interface WebSearchSettingsUpdate { + provider: string; + apiKey?: string; + baseUrl?: string; +} + export interface SlashCommand { command: string; title: string; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 6d75cb960..1cd95695f 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -7,6 +7,7 @@ import { listSlashCommands, updateProviderSettings, updateSettings, + updateWebSearchSettings, } from "@/lib/api"; describe("webui API helpers", () => { @@ -71,6 +72,20 @@ describe("webui API helpers", () => { ); }); + it("serializes web search settings updates", async () => { + await updateWebSearchSettings("tok", { + provider: "searxng", + baseUrl: "https://search.example.com", + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + it("maps generated session titles from the sessions list", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 01951d587..08b517c46 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -181,7 +181,12 @@ describe("App layout", () => { has_api_key: true, }, providers: [ - { name: "openai", label: "OpenAI", configured: true }, + { + name: "openai", + label: "OpenAI", + configured: true, + api_key_hint: "open••••-key", + }, { name: "openrouter", label: "OpenRouter", @@ -189,6 +194,16 @@ describe("App layout", () => { default_api_base: "https://openrouter.ai/api/v1", }, ], + web_search: { + provider: "brave", + api_key_hint: "BSAo••••ew20", + base_url: null, + providers: [ + { name: "duckduckgo", label: "DuckDuckGo", credential: "none" }, + { name: "brave", label: "Brave Search", credential: "api_key" }, + { name: "tavily", label: "Tavily", credential: "api_key" }, + ], + }, runtime: { config_path: "/tmp/config.json", }, @@ -219,8 +234,34 @@ describe("App layout", () => { expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" })); + expect(screen.getByRole("tab", { name: "LLM" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Web Search" })).toBeInTheDocument(); expect(screen.getByText("OpenRouter")).toBeInTheDocument(); expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); + fireEvent.click(screen.getByText("OpenAI")); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), { + target: { value: "unsaved-openai-key" }, + }); + fireEvent.click(screen.getByText("OpenRouter")); + fireEvent.click(screen.getByText("OpenAI")); + expect(screen.getByText("open••••-key")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Web Search" })); + expect(screen.getByText("Search provider")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument(); + expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), { + target: { value: "unsaved-brave-key" }, + }); + fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Tavily" })); + fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" })); + expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument(); }); it("returns from settings to an available chat instead of the blank start page", async () => { @@ -257,6 +298,15 @@ describe("App layout", () => { has_api_key: true, }, providers: [{ name: "openai", label: "OpenAI", configured: true }], + web_search: { + provider: "duckduckgo", + api_key_hint: null, + base_url: null, + providers: [ + { name: "duckduckgo", label: "DuckDuckGo", credential: "none" }, + { name: "brave", label: "Brave Search", credential: "api_key" }, + ], + }, runtime: { config_path: "/tmp/config.json", },