feat(webui): add BYOK web search settings

Let WebUI users configure the single web search provider credential from BYOK while keeping saved secrets masked and hot-reloaded for new searches.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-09 06:48:26 +00:00 committed by Xubin Ren
parent 7c1aa5ae31
commit 56eee06736
19 changed files with 861 additions and 66 deletions

View File

@ -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(

View File

@ -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)

View File

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

View File

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

View File

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

View File

@ -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<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("general");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({
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}
/>
<main className="min-w-0 flex-1 overflow-y-auto">
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<div className="mx-auto w-full max-w-[840px] px-6 py-10 sm:px-10 lg:py-14">
<div className="mb-8">
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
@ -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}
/>
)}
</div>
@ -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<SetStateAction<WebSearchSettingsUpdate>>;
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 (
<section className="space-y-4">
<SettingsGroup>
<SettingsRow
title={t("settings.byok.webSearch.provider")}
description={t("settings.byok.webSearch.providerHelp")}
>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
emptyLabel={t("settings.byok.webSearch.selectProvider")}
onChange={onChangeProvider}
/>
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow
title={t("settings.byok.webSearch.credentials")}
description={t("settings.byok.webSearch.noCredentialHelp")}
>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-1 text-[12px] font-medium text-emerald-700 dark:text-emerald-300">
{t("settings.byok.webSearch.noCredentialRequired")}
</span>
</SettingsRow>
) : null}
{selectedProvider?.credential === "api_key" ? (
<SettingsRow
title={t("settings.byok.apiKey")}
description={t("settings.byok.webSearch.apiKeyHelp")}
>
<div className="relative w-[280px] max-w-full">
{showKeyInput ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey ?? ""}
onChange={(event) =>
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]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKey}
aria-label={
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
{keyVisible ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</>
) : (
<>
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKeyEditing}
aria-label={t("settings.actions.edit")}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
</>
)}
</div>
</SettingsRow>
) : null}
{selectedProvider?.credential === "base_url" ? (
<SettingsRow
title={t("settings.byok.webSearch.baseUrl")}
description={t("settings.byok.webSearch.baseUrlHelp")}
>
<Input
value={form.baseUrl ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
}
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
className="h-9 w-[280px] rounded-full text-[13px]"
/>
</SettingsRow>
) : null}
<div className="flex min-h-[58px] items-center justify-between gap-4 px-4 py-3 sm:px-5">
<div className="text-[13px] text-muted-foreground">
{missingCredential
? t("settings.byok.webSearch.missingCredential")
: t("settings.byok.webSearch.saveHint")}
</div>
<Button
size="sm"
variant="outline"
onClick={onSave}
disabled={!dirty || missingCredential || saving}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</SettingsGroup>
</section>
);
}
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<string, boolean>;
editingProviderKeys: Record<string, boolean>;
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<SetStateAction<WebSearchSettingsUpdate>>;
onChangeWebSearchProvider: (provider: string) => void;
onToggleWebSearchKey: () => void;
onToggleWebSearchKeyEditing: () => void;
onResetProviderDraft: (provider: string) => void;
onResetWebSearchDraft: () => void;
onSaveWebSearch: () => void;
}) {
const { t } = useTranslation();
const [activePane, setActivePane] = useState<ByokPaneKey>("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({
</div>
);
};
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 (
<div className="space-y-6">
<p className="max-w-[42rem] text-[13px] leading-6 text-muted-foreground">
{t("settings.byok.description")}
</p>
<div className="space-y-8">
<section className="space-y-3">
<ByokSectionHeader
title={t("settings.byok.configuredSection")}
count={configuredProviders.length}
/>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
{configuredProviders.length > 0 ? (
<div className="divide-y divide-border/45">
{configuredProviders.map(renderProviderRow)}
</div>
) : (
<ByokEmptyState>{t("settings.byok.noConfiguredProviders")}</ByokEmptyState>
)}
</div>
</section>
<section className="space-y-3">
<ByokSectionHeader
title={t("settings.byok.notConfiguredSection")}
count={unconfiguredProviders.length}
/>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-border/45">
{visibleUnconfiguredProviders.map(renderProviderRow)}
</div>
</div>
{hiddenUnconfiguredCount > 0 ? (
<Button
<div
role="tablist"
aria-label={t("settings.byok.tabs.ariaLabel")}
className="grid rounded-[22px] border border-border/35 bg-muted/35 p-1 shadow-[inset_0_1px_2px_rgba(15,23,42,0.04)] backdrop-blur-xl sm:grid-cols-2"
>
{panes.map((pane) => {
const selected = activePane === pane.key;
return (
<button
key={pane.key}
type="button"
variant="ghost"
onClick={() => setShowAllUnconfigured(true)}
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
role="tab"
aria-selected={selected}
onClick={() => {
if (pane.key === activePane) return;
if (activePane === "llm" && expandedProvider) {
onResetProviderDraft(expandedProvider);
}
if (activePane === "web-search") {
onResetWebSearchDraft();
}
setActivePane(pane.key);
}}
className={cn(
"h-10 rounded-[18px] text-[13px] font-semibold transition-all",
selected
? "bg-background text-foreground shadow-[0_8px_28px_rgba(15,23,42,0.10)]"
: "text-muted-foreground hover:text-foreground",
)}
>
{t("settings.byok.showMore", { count: hiddenUnconfiguredCount })}
</Button>
) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? (
<Button
type="button"
variant="ghost"
onClick={() => setShowAllUnconfigured(false)}
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
{t("settings.byok.showLess")}
</Button>
) : null}
</section>
{pane.label}
</button>
);
})}
</div>
{activePane === "llm" ? (
<div className="space-y-8">
<section className="space-y-3">
<ByokSectionHeader
title={t("settings.byok.configuredSection")}
count={configuredProviders.length}
/>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
{configuredProviders.length > 0 ? (
<div className="divide-y divide-border/45">
{configuredProviders.map(renderProviderRow)}
</div>
) : (
<ByokEmptyState>{t("settings.byok.noConfiguredProviders")}</ByokEmptyState>
)}
</div>
</section>
<section className="space-y-3">
<ByokSectionHeader
title={t("settings.byok.notConfiguredSection")}
count={unconfiguredProviders.length}
/>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
<div className="divide-y divide-border/45">
{visibleUnconfiguredProviders.map(renderProviderRow)}
</div>
</div>
{hiddenUnconfiguredCount > 0 ? (
<Button
type="button"
variant="ghost"
onClick={() => setShowAllUnconfigured(true)}
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
{t("settings.byok.showMore", { count: hiddenUnconfiguredCount })}
</Button>
) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? (
<Button
type="button"
variant="ghost"
onClick={() => setShowAllUnconfigured(false)}
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
{t("settings.byok.showLess")}
</Button>
) : null}
</section>
</div>
) : (
<WebSearchByokSettings
settings={settings}
form={webSearchForm}
keyVisible={webSearchKeyVisible}
keyEditing={webSearchKeyEditing}
saving={webSearchSaving}
onChangeForm={onChangeWebSearchForm}
onChangeProvider={onChangeWebSearchProvider}
onToggleKey={onToggleWebSearchKey}
onToggleKeyEditing={onToggleWebSearchKeyEditing}
onSave={onSaveWebSearch}
/>
)}
</div>
);
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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<SettingsPayload> {
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<SettingsPayload>(
`${base}/api/settings/web-search/update?${query}`,
token,
);
}

View File

@ -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;

View File

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

View File

@ -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",
},