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: 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( self.tools.register(
WebSearchTool( WebSearchTool(
config=self.web_config.search, config=self.web_config.search,
proxy=self.web_config.proxy, proxy=self.web_config.proxy,
user_agent=self.web_config.user_agent, user_agent=self.web_config.user_agent,
config_loader=web_search_config_loader,
) )
) )
self.tools.register( self.tools.register(

View File

@ -7,7 +7,7 @@ import html
import json import json
import os import os
import re import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
import httpx import httpx
@ -91,16 +91,30 @@ class WebSearchTool(Tool):
) )
def __init__( 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 from nanobot.config.schema import WebSearchConfig
self.config = config if config is not None else WebSearchConfig() self.config = config if config is not None else WebSearchConfig()
self.proxy = proxy self.proxy = proxy
self.user_agent = user_agent if user_agent is not None else _DEFAULT_USER_AGENT 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: def _effective_provider(self) -> str:
"""Resolve the backend that execute() will actually use.""" """Resolve the backend that execute() will actually use."""
self._refresh_config()
provider = self.config.provider.strip().lower() or "brave" provider = self.config.provider.strip().lower() or "brave"
if provider == "duckduckgo": if provider == "duckduckgo":
return "duckduckgo" return "duckduckgo"
@ -134,6 +148,7 @@ class WebSearchTool(Tool):
return self._effective_provider() == "duckduckgo" return self._effective_provider() == "duckduckgo"
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
self._refresh_config()
provider = self.config.provider.strip().lower() or "brave" provider = self.config.provider.strip().lower() or "brave"
n = min(max(count or self.config.max_results, 1), 10) 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:]}" 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: def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content.""" """Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip() text = raw.strip()
@ -571,6 +585,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/provider/update": if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request) 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) m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m: if m:
return self._handle_session_messages(request, m.group(1)) 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, "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 { return {
"agent": { "agent": {
"model": defaults.model, "model": defaults.model,
@ -722,6 +745,12 @@ class WebSocketChannel(BaseChannel):
"has_api_key": bool(provider and provider.api_key), "has_api_key": bool(provider and provider.api_key),
}, },
"providers": providers, "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": { "runtime": {
"config_path": str(get_config_path().expanduser()), "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. # API key/base changes are picked up by the next provider snapshot refresh.
return _http_json_response(self._settings_payload(requires_restart=False)) 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 @staticmethod
def _is_webui_session_key(key: str) -> bool: def _is_webui_session_key(key: str) -> bool:
"""Return True when *key* belongs to the webui's websocket-only surface.""" """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 = Config()
config.agents.defaults.model = "openai/gpt-4o" config.agents.defaults.model = "openai/gpt-4o"
config.providers.openai.api_key = "secret-key" 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) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", 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["openai"]["api_key_hint"] == "secr••••-key"
assert providers["openrouter"]["configured"] is False assert providers["openrouter"]["configured"] is False
assert body["agent"]["has_api_key"] is True 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 "secret-key" not in settings.text
assert "brave-secret" not in settings.text
provider_updated = await _http_get( provider_updated = await _http_get(
"http://127.0.0.1:" "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.status_code == 200
assert updated.json()["requires_restart"] is False 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) saved = load_config(config_path)
assert saved.agents.defaults.model == "openrouter/test" assert saved.agents.defaults.model == "openrouter/test"
assert saved.agents.defaults.provider == "openrouter" assert saved.agents.defaults.provider == "openrouter"
assert saved.providers.openrouter.api_key == "sk-or-test" assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1" 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: finally:
await channel.stop() await channel.stop()
await server_task await server_task

View File

@ -13,7 +13,24 @@ import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagent import SubagentManager, SubagentStatus from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.search import GlobTool, GrepTool from nanobot.agent.tools.search import GlobTool, GrepTool
from nanobot.agent.tools.web import WebSearchTool
from nanobot.bus.queue import MessageBus 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 @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 # 2. The pagination info is correct
assert "pagination: limit=1, offset=1" in result assert "pagination: limit=1, offset=1" in result
# Count non-empty lines that start with src/ (file paths) # 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 assert len(file_lines) == 1

View File

@ -39,12 +39,18 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; 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 { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types"; import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
type SettingsSectionKey = "general" | "byok"; type SettingsSectionKey = "general" | "byok";
type ByokPaneKey = "llm" | "web-search";
interface SettingsViewProps { interface SettingsViewProps {
theme: "light" | "dark"; theme: "light" | "dark";
@ -71,12 +77,20 @@ export function SettingsView({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [providerSaving, setProviderSaving] = useState<string | null>(null); const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("general"); const [activeSection, setActiveSection] = useState<SettingsSectionKey>("general");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null); const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({}); const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = 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({ const [form, setForm] = useState({
model: "", model: "",
provider: "", provider: "",
@ -88,6 +102,11 @@ export function SettingsView({
model: payload.agent.model, model: payload.agent.model,
provider: payload.agent.provider, 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(() => { 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 toggleProviderKeyVisibility = (providerName: string) => {
const isVisible = visibleProviderKeys[providerName]; const isVisible = visibleProviderKeys[providerName];
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
@ -217,7 +319,7 @@ export function SettingsView({
onLogout={onLogout} 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="mx-auto w-full max-w-[840px] px-6 py-10 sm:px-10 lg:py-14">
<div className="mb-8"> <div className="mb-8">
<p className="mb-2 text-[13px] font-medium text-muted-foreground"> <p className="mb-2 text-[13px] font-medium text-muted-foreground">
@ -257,7 +359,7 @@ export function SettingsView({
saving={saving} saving={saving}
onSave={save} onSave={save}
onRestart={onRestart} onRestart={onRestart}
isRestarting={isRestarting} isRestarting={isRestarting}
onOpenByok={() => setActiveSection("byok")} onOpenByok={() => setActiveSection("byok")}
/> />
) : ( ) : (
@ -268,9 +370,11 @@ export function SettingsView({
visibleProviderKeys={visibleProviderKeys} visibleProviderKeys={visibleProviderKeys}
editingProviderKeys={editingProviderKeys} editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving} providerSaving={providerSaving}
onToggleProvider={(provider) => webSearchForm={webSearchForm}
setExpandedProvider((current) => (current === provider ? null : provider)) webSearchKeyVisible={webSearchKeyVisible}
} webSearchKeyEditing={webSearchKeyEditing}
webSearchSaving={webSearchSaving}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility} onToggleProviderKey={toggleProviderKeyVisibility}
onToggleProviderKeyEditing={toggleProviderKeyEditing} onToggleProviderKeyEditing={toggleProviderKeyEditing}
onChangeProviderForm={(provider, value) => onChangeProviderForm={(provider, value) =>
@ -284,6 +388,17 @@ export function SettingsView({
})) }))
} }
onSaveProvider={saveProvider} 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> </div>
@ -533,7 +648,7 @@ function ProviderPicker({
emptyLabel, emptyLabel,
onChange, onChange,
}: { }: {
providers: SettingsPayload["providers"]; providers: Array<{ name: string; label: string }>;
value: string; value: string;
emptyLabel: string; emptyLabel: string;
onChange: (provider: string) => void; 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({ function ByokSettings({
settings, settings,
expandedProvider, expandedProvider,
@ -591,11 +873,22 @@ function ByokSettings({
visibleProviderKeys, visibleProviderKeys,
editingProviderKeys, editingProviderKeys,
providerSaving, providerSaving,
webSearchForm,
webSearchKeyVisible,
webSearchKeyEditing,
webSearchSaving,
onToggleProvider, onToggleProvider,
onToggleProviderKey, onToggleProviderKey,
onToggleProviderKeyEditing, onToggleProviderKeyEditing,
onChangeProviderForm, onChangeProviderForm,
onSaveProvider, onSaveProvider,
onChangeWebSearchForm,
onChangeWebSearchProvider,
onToggleWebSearchKey,
onToggleWebSearchKeyEditing,
onResetProviderDraft,
onResetWebSearchDraft,
onSaveWebSearch,
}: { }: {
settings: SettingsPayload; settings: SettingsPayload;
expandedProvider: string | null; expandedProvider: string | null;
@ -603,13 +896,25 @@ function ByokSettings({
visibleProviderKeys: Record<string, boolean>; visibleProviderKeys: Record<string, boolean>;
editingProviderKeys: Record<string, boolean>; editingProviderKeys: Record<string, boolean>;
providerSaving: string | null; providerSaving: string | null;
webSearchForm: WebSearchSettingsUpdate;
webSearchKeyVisible: boolean;
webSearchKeyEditing: boolean;
webSearchSaving: boolean;
onToggleProvider: (provider: string) => void; onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void; onToggleProviderKey: (provider: string) => void;
onToggleProviderKeyEditing: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void;
onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void; onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void;
onSaveProvider: (provider: 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 { t } = useTranslation();
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
const configuredProviders = settings.providers.filter((provider) => provider.configured); const configuredProviders = settings.providers.filter((provider) => provider.configured);
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured); const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
@ -751,59 +1056,113 @@ function ByokSettings({
</div> </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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<p className="max-w-[42rem] text-[13px] leading-6 text-muted-foreground"> <p className="max-w-[42rem] text-[13px] leading-6 text-muted-foreground">
{t("settings.byok.description")} {t("settings.byok.description")}
</p> </p>
<div className="space-y-8"> <div
<section className="space-y-3"> role="tablist"
<ByokSectionHeader aria-label={t("settings.byok.tabs.ariaLabel")}
title={t("settings.byok.configuredSection")} 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"
count={configuredProviders.length} >
/> {panes.map((pane) => {
<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)]"> const selected = activePane === pane.key;
{configuredProviders.length > 0 ? ( return (
<div className="divide-y divide-border/45"> <button
{configuredProviders.map(renderProviderRow)} key={pane.key}
</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" type="button"
variant="ghost" role="tab"
onClick={() => setShowAllUnconfigured(true)} aria-selected={selected}
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground" 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 })} {pane.label}
</Button> </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> </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> </div>
); );
} }

View File

@ -123,7 +123,28 @@
"hideApiKey": "Hide API key", "hideApiKey": "Hide API key",
"noConfiguredProviders": "No configured providers", "noConfiguredProviders": "No configured providers",
"configureFirst": "Configure a provider in BYOK first.", "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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "Ocultar API key", "hideApiKey": "Ocultar API key",
"noConfiguredProviders": "No hay proveedores configurados", "noConfiguredProviders": "No hay proveedores configurados",
"configureFirst": "Configura primero un proveedor en BYOK.", "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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "Masquer l'API key", "hideApiKey": "Masquer l'API key",
"noConfiguredProviders": "Aucun fournisseur configuré", "noConfiguredProviders": "Aucun fournisseur configuré",
"configureFirst": "Configurez d'abord un fournisseur dans BYOK.", "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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "Sembunyikan API key", "hideApiKey": "Sembunyikan API key",
"noConfiguredProviders": "Belum ada provider terkonfigurasi", "noConfiguredProviders": "Belum ada provider terkonfigurasi",
"configureFirst": "Konfigurasikan provider di BYOK terlebih dahulu.", "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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "API key を隠す", "hideApiKey": "API key を隠す",
"noConfiguredProviders": "設定済み provider がありません", "noConfiguredProviders": "設定済み provider がありません",
"configureFirst": "先に BYOK で 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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "API key 숨기기", "hideApiKey": "API key 숨기기",
"noConfiguredProviders": "설정된 provider가 없습니다", "noConfiguredProviders": "설정된 provider가 없습니다",
"configureFirst": "먼저 BYOK에서 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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "Ẩn API key", "hideApiKey": "Ẩn API key",
"noConfiguredProviders": "Chưa có provider đã cấu hình", "noConfiguredProviders": "Chưa có provider đã cấu hình",
"configureFirst": "Hãy cấu hình provider trong BYOK trước.", "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": { "chat": {

View File

@ -111,7 +111,28 @@
"hideApiKey": "隐藏 API key", "hideApiKey": "隐藏 API key",
"noConfiguredProviders": "没有已配置的 provider", "noConfiguredProviders": "没有已配置的 provider",
"configureFirst": "请先在 BYOK 里配置 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": { "chat": {

View File

@ -104,7 +104,28 @@
"hideApiKey": "隱藏 API key", "hideApiKey": "隱藏 API key",
"noConfiguredProviders": "沒有已設定的 provider", "noConfiguredProviders": "沒有已設定的 provider",
"configureFirst": "請先在 BYOK 設定 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": { "chat": {

View File

@ -4,6 +4,7 @@ import type {
SettingsPayload, SettingsPayload,
SettingsUpdate, SettingsUpdate,
SlashCommand, SlashCommand,
WebSearchSettingsUpdate,
} from "./types"; } from "./types";
export class ApiError extends Error { export class ApiError extends Error {
@ -168,3 +169,18 @@ export async function updateProviderSettings(
token, 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; api_base?: string | null;
default_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: { runtime: {
config_path: string; config_path: string;
}; };
@ -99,6 +109,12 @@ export interface ProviderSettingsUpdate {
apiBase?: string; apiBase?: string;
} }
export interface WebSearchSettingsUpdate {
provider: string;
apiKey?: string;
baseUrl?: string;
}
export interface SlashCommand { export interface SlashCommand {
command: string; command: string;
title: string; title: string;

View File

@ -7,6 +7,7 @@ import {
listSlashCommands, listSlashCommands,
updateProviderSettings, updateProviderSettings,
updateSettings, updateSettings,
updateWebSearchSettings,
} from "@/lib/api"; } from "@/lib/api";
describe("webui API helpers", () => { 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 () => { it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
ok: true, ok: true,

View File

@ -181,7 +181,12 @@ describe("App layout", () => {
has_api_key: true, has_api_key: true,
}, },
providers: [ providers: [
{ name: "openai", label: "OpenAI", configured: true }, {
name: "openai",
label: "OpenAI",
configured: true,
api_key_hint: "open••••-key",
},
{ {
name: "openrouter", name: "openrouter",
label: "OpenRouter", label: "OpenRouter",
@ -189,6 +194,16 @@ describe("App layout", () => {
default_api_base: "https://openrouter.ai/api/v1", 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: { runtime: {
config_path: "/tmp/config.json", config_path: "/tmp/config.json",
}, },
@ -219,8 +234,34 @@ describe("App layout", () => {
expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" })); 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.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); 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 () => { 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, has_api_key: true,
}, },
providers: [{ name: "openai", label: "OpenAI", configured: 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: { runtime: {
config_path: "/tmp/config.json", config_path: "/tmp/config.json",
}, },