mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
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:
parent
7c1aa5ae31
commit
56eee06736
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user