From 2cc32ca07c78f99e07c9e7dedc79e8a6a1c1c2c0 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 8 May 2026 15:31:52 +0000 Subject: [PATCH] feat(webui): redesign settings and BYOK configuration Co-authored-by: Cursor --- nanobot/channels/websocket.py | 91 +- tests/channels/test_websocket_channel.py | 22 +- webui/src/App.tsx | 73 +- webui/src/components/MarkdownTextRenderer.tsx | 4 +- webui/src/components/MessageBubble.tsx | 4 +- webui/src/components/Sidebar.tsx | 13 +- .../src/components/settings/SettingsView.tsx | 840 ++++++++++++++++-- webui/src/components/thread/AskUserPrompt.tsx | 4 +- .../src/components/thread/ThreadComposer.tsx | 4 +- webui/src/components/thread/ThreadHeader.tsx | 80 +- webui/src/components/thread/ThreadShell.tsx | 3 - webui/src/i18n/locales/en/common.json | 72 +- webui/src/i18n/locales/es/common.json | 73 +- webui/src/i18n/locales/fr/common.json | 73 +- webui/src/i18n/locales/id/common.json | 73 +- webui/src/i18n/locales/ja/common.json | 73 +- webui/src/i18n/locales/ko/common.json | 73 +- webui/src/i18n/locales/vi/common.json | 73 +- webui/src/i18n/locales/zh-CN/common.json | 72 +- webui/src/i18n/locales/zh-TW/common.json | 73 +- webui/src/lib/api.ts | 23 +- webui/src/lib/types.ts | 10 + webui/src/tests/api.test.ts | 16 + webui/src/tests/app-layout.test.tsx | 28 +- webui/src/tests/i18n.test.tsx | 25 + 25 files changed, 1679 insertions(+), 216 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index e79639bd9..b121bb4de 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -171,7 +171,7 @@ def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]] """Parse normalized path and query parameters in one pass.""" parsed = urlparse("ws://x" + path_with_query) path = _strip_trailing_slash(parsed.path or "/") - return path, parse_qs(parsed.query) + return path, parse_qs(parsed.query, keep_blank_values=True) def _normalize_http_path(path_with_query: str) -> str: @@ -189,6 +189,14 @@ def _query_first(query: dict[str, list[str]], key: str) -> str | None: return values[0] if values else None +def _mask_secret_hint(secret: str | None) -> str | None: + if not secret: + return None + if len(secret) <= 8: + return "••••" + return f"{secret[:4]}••••{secret[-4:]}" + + def _parse_inbound_payload(raw: str) -> str | None: """Parse a client frame into text; return None for empty or unrecognized content.""" text = raw.strip() @@ -560,6 +568,9 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings/update": return self._handle_settings_update(request) + if got == "/api/settings/provider/update": + return self._handle_settings_provider_update(request) + m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -688,6 +699,21 @@ class WebSocketChannel(BaseChannel): if defaults.provider != "auto": spec = find_by_name(defaults.provider) selected_provider = spec.name if spec else provider_name + providers = [] + for spec in PROVIDERS: + provider_config = getattr(config.providers, spec.name, None) + if provider_config is None or spec.is_oauth or spec.is_local: + continue + providers.append( + { + "name": spec.name, + "label": spec.label, + "configured": bool(provider_config.api_key), + "api_key_hint": _mask_secret_hint(provider_config.api_key), + "api_base": provider_config.api_base, + "default_api_base": spec.default_api_base or None, + } + ) return { "agent": { "model": defaults.model, @@ -695,12 +721,7 @@ class WebSocketChannel(BaseChannel): "resolved_provider": provider_name, "has_api_key": bool(provider and provider.api_key), }, - "providers": [ - {"name": "auto", "label": "Auto"} - ] + [ - {"name": spec.name, "label": spec.label} - for spec in PROVIDERS - ], + "providers": providers, "runtime": { "config_path": str(get_config_path().expanduser()), }, @@ -739,16 +760,66 @@ class WebSocketChannel(BaseChannel): provider = _query_first(query, "provider") if provider is not None: - provider = provider.strip() or "auto" - if provider != "auto" and find_by_name(provider) is None: + provider = provider.strip() + if not provider: + return _http_error(400, "provider is required") + if find_by_name(provider) is None: return _http_error(400, "unknown provider") + provider_config = getattr(config.providers, provider, None) + if provider_config is None or not provider_config.api_key: + return _http_error(400, "provider is not configured") if defaults.provider != provider: defaults.provider = provider changed = True if changed: save_config(config) - return _http_json_response(self._settings_payload(requires_restart=changed)) + # LLM provider/model changes are hot-reloaded by AgentLoop before each + # new turn via the provider snapshot loader, so a restart is unnecessary. + return _http_json_response(self._settings_payload(requires_restart=False)) + + def _handle_settings_provider_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 + from nanobot.providers.registry import find_by_name + + query = _parse_query(request.path) + provider_name = (_query_first(query, "provider") or "").strip() + if not provider_name: + return _http_error(400, "provider is required") + spec = find_by_name(provider_name) + if spec is None or spec.is_oauth or spec.is_local: + return _http_error(400, "unknown provider") + + config = load_config() + provider_config = getattr(config.providers, spec.name, None) + if provider_config is None: + return _http_error(400, "unknown provider") + + changed = False + if "api_key" in query or "apiKey" in query: + api_key = _query_first(query, "api_key") + if api_key is None: + api_key = _query_first(query, "apiKey") + api_key = (api_key or "").strip() or None + if provider_config.api_key != api_key: + provider_config.api_key = api_key + changed = True + + if "api_base" in query or "apiBase" in query: + api_base = _query_first(query, "api_base") + if api_base is None: + api_base = _query_first(query, "apiBase") + api_base = (api_base or "").strip() or None + if provider_config.api_base != api_base: + provider_config.api_base = api_base + changed = True + + if changed: + save_config(config) + # API key/base changes are picked up by the next provider snapshot refresh. + return _http_json_response(self._settings_payload(requires_restart=False)) @staticmethod def _is_webui_session_key(key: str) -> bool: diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index e757551f2..0c22b9d67 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -542,10 +542,26 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( body = settings.json() assert body["agent"]["model"] == "openai/gpt-4o" assert body["agent"]["provider"] == "openai" - assert {"name": "auto", "label": "Auto"} in body["providers"] + providers = {provider["name"]: provider for provider in body["providers"]} + assert providers["openai"]["configured"] is True + assert providers["openai"]["api_key_hint"] == "secr••••-key" + assert providers["openrouter"]["configured"] is False assert body["agent"]["has_api_key"] is True assert "secret-key" not in settings.text + provider_updated = await _http_get( + "http://127.0.0.1:" + f"{port}/api/settings/provider/update?provider=openrouter" + "&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", + headers={"Authorization": "Bearer tok"}, + ) + assert provider_updated.status_code == 200 + provider_body = provider_updated.json() + assert provider_body["requires_restart"] is False + provider_rows = {provider["name"]: provider for provider in provider_body["providers"]} + assert provider_rows["openrouter"]["configured"] is True + assert "sk-or-test" not in provider_updated.text + updated = await _http_get( "http://127.0.0.1:" f"{port}/api/settings/update?model=openrouter/test" @@ -553,11 +569,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( headers={"Authorization": "Bearer tok"}, ) assert updated.status_code == 200 - assert updated.json()["requires_restart"] is True + assert updated.json()["requires_restart"] is False 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" finally: await channel.stop() await server_task diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6798247d3..6a74b6df1 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -253,6 +253,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: const lastSessionsLen = useRef(0); const restartSawDisconnectRef = useRef(false); const [restartToast, setRestartToast] = useState(null); + const [isRestarting, setIsRestarting] = useState(false); useEffect(() => { try { @@ -334,6 +335,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: const chatId = activeSession?.chatId ?? client.defaultChatId; if (!chatId) return; restartSawDisconnectRef.current = false; + setIsRestarting(true); try { window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now())); } catch { @@ -362,6 +364,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: } catch { // ignore storage errors } + setIsRestarting(false); setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) })); window.setTimeout(() => setRestartToast(null), 3_500); }); @@ -396,10 +399,16 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: : t("app.brand"); useEffect(() => { + if (view === "settings") { + document.title = t("app.documentTitle.chat", { + title: t("settings.sidebar.title"), + }); + return; + } document.title = activeSession ? t("app.documentTitle.chat", { title: headerTitle }) : t("app.documentTitle.base"); - }, [activeSession, headerTitle, i18n.resolvedLanguage, t]); + }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); const sidebarProps = { sessions, @@ -409,43 +418,49 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: onSelect: onSelectChat, onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), + onOpenSettings, }; + const showMainSidebar = view !== "settings"; return (
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} - +
+ +
+ + ) : null} - setMobileSidebarOpen(open)} - > - setMobileSidebarOpen(open)} > - - - + + + + + ) : null}
{view === "settings" ? ( @@ -456,6 +471,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: onModelNameChange={onModelNameChange} onLogout={onLogout} onRestart={onRestart} + isRestarting={isRestarting} /> ) : ( )} diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index fe9ab40ed..1ccc0838f 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -24,9 +24,9 @@ export default function MarkdownTextRenderer({ return (
{message.content} @@ -87,7 +87,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { const media = message.media ?? []; const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; return ( -
+
{empty && message.isStreaming ? ( ) : ( diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index ff8338181..b7dadfbea 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { Menu, Search, + Settings, SquarePen, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -20,6 +21,7 @@ interface SidebarProps { onNewChat: () => void; onSelect: (key: string) => void; onRequestDelete: (key: string, label: string) => void; + onOpenSettings: () => void; onCollapse: () => void; } @@ -113,7 +115,16 @@ export function Sidebar(props: SidebarProps) { />
-
+
+
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 5586e9d08..8b0bc5914 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1,15 +1,51 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ChevronLeft, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; +import { + Bot, + Brain, + ChevronLeft, + ChevronDown, + Check, + Cloud, + Cpu, + Database, + Eye, + EyeOff, + Pencil, + Gem, + Grid3X3, + Hexagon, + Loader2, + LogOut, + KeyRound, + Layers, + Moon, + Orbit, + RotateCcw, + Settings, + Sparkles, + Triangle, + Waves, + Zap, + type LucideIcon, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; -import { fetchSettings, updateSettings } from "@/lib/api"; +import { fetchSettings, updateProviderSettings, updateSettings } from "@/lib/api"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; import type { SettingsPayload } from "@/lib/types"; +type SettingsSectionKey = "general" | "byok"; + interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; @@ -17,22 +53,33 @@ interface SettingsViewProps { onModelNameChange: (modelName: string | null) => void; onLogout?: () => void; onRestart?: () => void; + isRestarting?: boolean; } export function SettingsView({ + theme, + onToggleTheme, onBackToChat, onModelNameChange, onLogout, onRestart, + isRestarting = false, }: SettingsViewProps) { + const { t } = useTranslation(); const { token } = useClient(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [providerSaving, setProviderSaving] = useState(null); const [error, setError] = useState(null); + const [activeSection, setActiveSection] = useState("general"); + const [expandedProvider, setExpandedProvider] = useState(null); + const [providerForms, setProviderForms] = useState>({}); + const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); + const [editingProviderKeys, setEditingProviderKeys] = useState>({}); const [form, setForm] = useState({ model: "", - provider: "auto", + provider: "", }); const applyPayload = useCallback((payload: SettingsPayload) => { @@ -64,6 +111,20 @@ export function SettingsView({ }; }, [applyPayload, token]); + useEffect(() => { + if (!settings) return; + setProviderForms((prev) => { + const next = { ...prev }; + for (const provider of settings.providers) { + next[provider.name] = { + apiKey: next[provider.name]?.apiKey ?? "", + apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "", + }; + } + return next; + }); + }, [settings]); + const dirty = useMemo(() => { if (!settings) return false; return ( @@ -76,7 +137,10 @@ export function SettingsView({ if (!dirty || saving) return; setSaving(true); try { - const payload = await updateSettings(token, form); + const payload = await updateSettings(token, { + model: form.model, + ...(form.provider ? { provider: form.provider } : {}), + }); applyPayload(payload); onModelNameChange(payload.agent.model || null); setError(null); @@ -87,63 +151,242 @@ export function SettingsView({ } }; + const saveProvider = async (providerName: string) => { + if (providerSaving) return; + const provider = settings?.providers.find((item) => item.name === providerName); + if (!provider) return; + const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; + const apiKey = providerForm.apiKey.trim(); + if (!provider.configured && !apiKey) { + setError(t("settings.byok.apiKeyRequired")); + return; + } + setProviderSaving(providerName); + try { + const payload = await updateProviderSettings(token, { + provider: providerName, + apiKey: apiKey || undefined, + apiBase: providerForm.apiBase.trim(), + }); + applyPayload(payload); + setProviderForms((prev) => ({ + ...prev, + [providerName]: { + apiKey: "", + apiBase: providerForm.apiBase.trim(), + }, + })); + setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); + setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setProviderSaving(null); + } + }; + + const toggleProviderKeyVisibility = (providerName: string) => { + const isVisible = visibleProviderKeys[providerName]; + setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible })); + }; + + const toggleProviderKeyEditing = (providerName: string) => { + setEditingProviderKeys((prev) => { + const nextEditing = !prev[providerName]; + if (!nextEditing) { + setProviderForms((forms) => ({ + ...forms, + [providerName]: { + apiKey: "", + apiBase: forms[providerName]?.apiBase ?? "", + }, + })); + setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false })); + } + return { ...prev, [providerName]: nextEditing }; + }); + }; + return ( -
-
- +
+ -

General

- - {loading ? ( -
- - Loading settings... +
+
+
+

+ {t("settings.sidebar.title")} +

+

+ {t(`settings.nav.${activeSection}`)} +

- ) : error ? ( - - - {error} - - - ) : settings ? ( - - ) : null} + + {loading ? ( +
+ + {t("settings.status.loading")} +
+ ) : error && !settings ? ( + + + {error} + + + ) : settings ? ( +
+ {error ? ( +
+ {error} +
+ ) : null} + {activeSection === "general" ? ( + setActiveSection("byok")} + /> + ) : ( + + setExpandedProvider((current) => (current === provider ? null : provider)) + } + onToggleProviderKey={toggleProviderKeyVisibility} + onToggleProviderKeyEditing={toggleProviderKeyEditing} + onChangeProviderForm={(provider, value) => + setProviderForms((prev) => ({ + ...prev, + [provider]: { + apiKey: prev[provider]?.apiKey ?? "", + apiBase: prev[provider]?.apiBase ?? "", + ...value, + }, + })) + } + onSaveProvider={saveProvider} + /> + )} +
+ ) : null} +
); } -function SettingsSection({ +const SETTINGS_NAV_ITEMS = [ + { key: "general", icon: Settings }, + { key: "byok", icon: KeyRound }, +] as const; + +function SettingsSidebar({ + activeSection, + onSelectSection, + onBackToChat, + onLogout, +}: { + activeSection: SettingsSectionKey; + onSelectSection: (section: SettingsSectionKey) => void; + onBackToChat: () => void; + onLogout?: () => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} + +function GeneralSettings({ + theme, + onToggleTheme, form, setForm, settings, dirty, saving, onSave, - onLogout, onRestart, + isRestarting, + onOpenByok, }: { + theme: "light" | "dark"; + onToggleTheme: () => void; form: { model: string; provider: string; }; - setForm: React.Dispatch>; @@ -151,37 +394,80 @@ function SettingsSection({ dirty: boolean; saving: boolean; onSave: () => void; - onLogout?: () => void; onRestart?: () => void; + isRestarting?: boolean; + onOpenByok: () => void; }) { const { t } = useTranslation(); + const configuredProviders = settings.providers.filter((provider) => provider.configured); + const providerValue = configuredProviders.some((provider) => provider.name === form.provider) + ? form.provider + : ""; return ( -
+
-

AI

+ {t("settings.sections.interface")} - - + + {t("settings.values.light")} + + + {t("settings.values.dark")} + + - + + + + +
+ +
+ {t("settings.sections.ai")} + + + setForm((prev) => ({ ...prev, provider }))} + /> + + + setForm((prev) => ({ ...prev, model: event.target.value }))} - className="h-8 w-[280px]" + className="h-8 w-[280px] rounded-full text-[13px]" /> @@ -193,39 +479,46 @@ function SettingsSection({ onSave={onSave} /> ) : null} - -
- -
-

Interface

- - - - + {configuredProviders.length === 0 ? ( + + + + ) : null}
{onRestart && (
-

{t("app.system.section")}

+ {t("settings.sections.system")} - - - -
- )} - - {onLogout && ( -
-

{t("app.account.section")}

- - - + + + {settings.runtime.config_path || t("settings.values.notAvailable")} +
@@ -234,25 +527,377 @@ function SettingsSection({ ); } -function SettingsGroup({ children }: { children: React.ReactNode }) { +function ProviderPicker({ + providers, + value, + emptyLabel, + onChange, +}: { + providers: SettingsPayload["providers"]; + value: string; + emptyLabel: string; + onChange: (provider: string) => void; +}) { + const selectedProvider = providers.find((provider) => provider.name === value) ?? null; + const disabled = providers.length === 0; + return ( -
-
{children}
+ + + + + + {providers.map((provider) => { + const selected = provider.name === value; + return ( + onChange(provider.name)} + className={cn( + "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-3 py-2 text-[13px]", + "focus:bg-muted focus:text-foreground", + selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary", + )} + > + {provider.label} + {selected ? : null} + + ); + })} + + + ); +} + +function ByokSettings({ + settings, + expandedProvider, + providerForms, + visibleProviderKeys, + editingProviderKeys, + providerSaving, + onToggleProvider, + onToggleProviderKey, + onToggleProviderKeyEditing, + onChangeProviderForm, + onSaveProvider, +}: { + settings: SettingsPayload; + expandedProvider: string | null; + providerForms: Record; + visibleProviderKeys: Record; + editingProviderKeys: Record; + providerSaving: string | null; + 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; +}) { + const { t } = useTranslation(); + const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); + const configuredProviders = settings.providers.filter((provider) => provider.configured); + const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured); + const initialUnconfiguredCount = 6; + const visibleUnconfiguredProviders = showAllUnconfigured + ? unconfiguredProviders + : unconfiguredProviders.slice(0, initialUnconfiguredCount); + const hiddenUnconfiguredCount = Math.max( + 0, + unconfiguredProviders.length - visibleUnconfiguredProviders.length, + ); + const renderProviderRow = (provider: SettingsPayload["providers"][number]) => { + const expanded = expandedProvider === provider.name; + const form = providerForms[provider.name] ?? { + apiKey: "", + apiBase: provider.api_base ?? provider.default_api_base ?? "", + }; + const saving = providerSaving === provider.name; + const keyVisible = !!visibleProviderKeys[provider.name]; + const editingKey = !provider.configured || !!editingProviderKeys[provider.name]; + return ( +
+ + + {expanded ? ( +
+ + +
+ +
+
+ ) : null} +
+ ); + }; + return ( +
+

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

+
+
+ +
+ {configuredProviders.length > 0 ? ( +
+ {configuredProviders.map(renderProviderRow)} +
+ ) : ( + {t("settings.byok.noConfiguredProviders")} + )} +
+
+ +
+ +
+
+ {visibleUnconfiguredProviders.map(renderProviderRow)} +
+
+ {hiddenUnconfiguredCount > 0 ? ( + + ) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? ( + + ) : null} +
+
+
+ ); +} + +function ByokSectionHeader({ title, count }: { title: string; count: number }) { + return ( +
+

+ {title} +

+ + {count} + +
+ ); +} + +function ByokEmptyState({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const PROVIDER_ICONS: Record = { + custom: Hexagon, + openrouter: Sparkles, + aihubmix: Triangle, + anthropic: Brain, + openai: Bot, + deepseek: Waves, + zhipu: Grid3X3, + dashscope: Cloud, + moonshot: Moon, + minimax: Zap, + minimax_anthropic: Brain, + groq: Cpu, + huggingface: Layers, + gemini: Gem, + mistral: Orbit, + siliconflow: Layers, + volcengine: Cloud, + volcengine_coding_plan: Cloud, + byteplus: Cloud, + byteplus_coding_plan: Cloud, + qianfan: Database, + azure_openai: Cloud, + bedrock: Database, +}; + +function ProviderIcon({ provider }: { provider: string }) { + const Icon = PROVIDER_ICONS[provider] ?? Hexagon; + return ( + + + + ); +} + +function SettingsSectionTitle({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function SettingsGroup({ children }: { children: ReactNode }) { + return ( +
+
{children}
); } function SettingsRow({ title, + description, children, }: { title: string; - children?: React.ReactNode; + description?: string; + children?: ReactNode; }) { return ( -
+
-
{title}
+
{title}
+ {description ? ( +
+ {description} +
+ ) : null}
{children ?
{children}
: null}
@@ -270,13 +915,14 @@ function SettingsFooter({ saved: boolean; onSave: () => void; }) { + const { t } = useTranslation(); return ( -
-
- {saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."} +
+
+ {saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")}
-
); diff --git a/webui/src/components/thread/AskUserPrompt.tsx b/webui/src/components/thread/AskUserPrompt.tsx index 3ab20f5e8..4de76307c 100644 --- a/webui/src/components/thread/AskUserPrompt.tsx +++ b/webui/src/components/thread/AskUserPrompt.tsx @@ -49,7 +49,7 @@ export function AskUserPrompt({
-

+

{question}

@@ -94,7 +94,7 @@ export function AskUserPrompt({ placeholder="Type your own answer..." className={cn( "min-h-9 flex-1 resize-none rounded-[10px] border border-border/70 bg-background", - "px-3 py-2 text-sm leading-5 outline-none placeholder:text-muted-foreground", + "px-3 py-2 text-[13.5px] leading-5 outline-none placeholder:text-muted-foreground", "focus-visible:ring-1 focus-visible:ring-primary/40", )} /> diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 824a70938..572ac3966 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -473,8 +473,8 @@ export function ThreadComposer({ className={cn( "w-full resize-none bg-transparent", isHero - ? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6" - : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", + ? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6" + : "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5", "placeholder:text-muted-foreground/70", "focus:outline-none focus-visible:outline-none", "disabled:cursor-not-allowed", diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx index 1fb8688a5..72136b10f 100644 --- a/webui/src/components/thread/ThreadHeader.tsx +++ b/webui/src/components/thread/ThreadHeader.tsx @@ -1,4 +1,4 @@ -import { Menu, Moon, Settings, Sun } from "lucide-react"; +import { Menu, Moon, Sun } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -9,7 +9,6 @@ interface ThreadHeaderProps { onToggleSidebar: () => void; theme: "light" | "dark"; onToggleTheme: () => void; - onOpenSettings: () => void; hideSidebarToggleOnDesktop?: boolean; minimal?: boolean; } @@ -19,7 +18,6 @@ export function ThreadHeader({ onToggleSidebar, theme, onToggleTheme, - onOpenSettings, hideSidebarToggleOnDesktop = false, minimal = false, }: ThreadHeaderProps) { @@ -39,30 +37,7 @@ export function ThreadHeader({ > -
- - -
+
); } @@ -87,32 +62,35 @@ export function ThreadHeader({
-
- - -
+
); } + +function ThemeButton({ + theme, + onToggleTheme, + label, +}: { + theme: "light" | "dark"; + onToggleTheme: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 464dd38cb..948161072 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -34,7 +34,6 @@ interface ThreadShellProps { onTurnEnd?: () => void; theme?: "light" | "dark"; onToggleTheme?: () => void; - onOpenSettings?: () => void; hideSidebarToggleOnDesktop?: boolean; } @@ -78,7 +77,6 @@ export function ThreadShell({ onTurnEnd, theme = "light", onToggleTheme = () => {}, - onOpenSettings = () => {}, hideSidebarToggleOnDesktop = false, }: ThreadShellProps) { const { t } = useTranslation(); @@ -312,7 +310,6 @@ export function ThreadShell({ onToggleSidebar={onToggleSidebar} theme={theme} onToggleTheme={onToggleTheme} - onOpenSettings={onOpenSettings} hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop} minimal={!session && !loading} /> diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index d51ae150f..fbc1ec828 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -24,7 +24,8 @@ "system": { "section": "System", "restartHint": "Restart nanobot to apply runtime changes.", - "restart": "Restart nanobot" + "restart": "Restart nanobot", + "restarting": "Restarting..." }, "restart": { "completed": "Restart completed in {{seconds}}s." @@ -56,6 +57,75 @@ "ariaLabel": "Change language" } }, + "settings": { + "backToChat": "Back to chat", + "sidebar": { + "title": "Settings", + "ariaLabel": "Settings sections" + }, + "nav": { + "general": "General", + "byok": "BYOK" + }, + "sections": { + "interface": "Interface", + "ai": "AI", + "system": "System" + }, + "rows": { + "theme": "Theme", + "language": "Language", + "provider": "Provider", + "model": "Model", + "restart": "Restart nanobot", + "configPath": "Config path" + }, + "help": { + "theme": "Switch between light and dark appearance.", + "language": "Choose the language used by the WebUI.", + "provider": "Select the provider that should serve new model requests.", + "model": "Set the default model name used by nanobot.", + "configPath": "The gateway configuration file currently in use." + }, + "values": { + "light": "Light", + "dark": "Dark", + "notAvailable": "Not available" + }, + "status": { + "loading": "Loading settings...", + "loadError": "Could not load settings", + "unsaved": "Unsaved changes.", + "savedRestart": "Saved. Restart nanobot to apply." + }, + "actions": { + "save": "Save", + "saving": "Saving", + "edit": "Edit", + "cancel": "Cancel" + }, + "byok": { + "description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.", + "configured": "Configured", + "notConfigured": "Not configured", + "configuredSection": "Configured", + "notConfiguredSection": "Not configured", + "showMore": "Show {{count}} more", + "showLess": "Show fewer", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "Enter API key", + "apiKeyConfiguredPlaceholder": "Leave blank to keep the current key", + "configuredKeyHint": "Configured key", + "apiBasePlaceholder": "Use provider default", + "apiKeyRequired": "API key is required to configure this provider.", + "showApiKey": "Show API key", + "hideApiKey": "Hide API key", + "noConfiguredProviders": "No configured providers", + "configureFirst": "Configure a provider in BYOK first.", + "openByok": "Open BYOK" + } + }, "chat": { "fallbackTitle": "Chat {{id}}", "loading": "Loading…", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 58312e255..55814a8b9 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -12,7 +12,8 @@ "system": { "section": "Sistema", "restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.", - "restart": "Reiniciar nanobot" + "restart": "Reiniciar nanobot", + "restarting": "Reiniciando..." }, "restart": { "completed": "Reinicio completado en {{seconds}} s." @@ -31,11 +32,81 @@ "newChat": "Nuevo chat", "recent": "Recientes", "refreshSessions": "Actualizar sesiones", + "settings": "Configuración", "language": { "label": "Idioma", "ariaLabel": "Cambiar idioma" } }, + "settings": { + "backToChat": "Volver al chat", + "sidebar": { + "title": "Configuración", + "ariaLabel": "Secciones de configuración" + }, + "nav": { + "general": "General", + "byok": "BYOK" + }, + "sections": { + "interface": "Interfaz", + "ai": "IA", + "system": "Sistema" + }, + "rows": { + "theme": "Tema", + "language": "Idioma", + "provider": "Proveedor", + "model": "Modelo", + "restart": "Reiniciar nanobot", + "configPath": "Ruta de configuración" + }, + "help": { + "theme": "Cambia entre apariencia clara y oscura.", + "language": "Elige el idioma usado por la WebUI.", + "provider": "Selecciona el proveedor para nuevas solicitudes de modelo.", + "model": "Define el nombre del modelo predeterminado que usa nanobot.", + "configPath": "El archivo de configuración que usa actualmente el gateway." + }, + "values": { + "light": "Claro", + "dark": "Oscuro", + "notAvailable": "No disponible" + }, + "status": { + "loading": "Cargando configuración...", + "loadError": "No se pudo cargar la configuración", + "unsaved": "Hay cambios sin guardar.", + "savedRestart": "Guardado. Reinicia nanobot para aplicar." + }, + "actions": { + "save": "Guardar", + "saving": "Guardando", + "edit": "Editar", + "cancel": "Cancelar" + }, + "byok": { + "description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.", + "configured": "Configurado", + "notConfigured": "Sin configurar", + "configuredSection": "Configurados", + "notConfiguredSection": "Sin configurar", + "showMore": "Mostrar {{count}} más", + "showLess": "Mostrar menos", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "Introduce la API key", + "apiKeyConfiguredPlaceholder": "Deja vacío para conservar la key actual", + "configuredKeyHint": "Key configurada", + "apiBasePlaceholder": "Usar el valor predeterminado del proveedor", + "apiKeyRequired": "Se requiere una API key para configurar este proveedor.", + "showApiKey": "Mostrar API key", + "hideApiKey": "Ocultar API key", + "noConfiguredProviders": "No hay proveedores configurados", + "configureFirst": "Configura primero un proveedor en BYOK.", + "openByok": "Abrir BYOK" + } + }, "chat": { "fallbackTitle": "Chat {{id}}", "loading": "Cargando…", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index a646fc5f9..d544840e9 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -12,7 +12,8 @@ "system": { "section": "Système", "restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.", - "restart": "Redémarrer nanobot" + "restart": "Redémarrer nanobot", + "restarting": "Redémarrage..." }, "restart": { "completed": "Redémarrage terminé en {{seconds}} s." @@ -31,11 +32,81 @@ "newChat": "Nouvelle discussion", "recent": "Récentes", "refreshSessions": "Actualiser les sessions", + "settings": "Paramètres", "language": { "label": "Langue", "ariaLabel": "Changer de langue" } }, + "settings": { + "backToChat": "Retour à la discussion", + "sidebar": { + "title": "Paramètres", + "ariaLabel": "Sections des paramètres" + }, + "nav": { + "general": "Général", + "byok": "BYOK" + }, + "sections": { + "interface": "Interface", + "ai": "IA", + "system": "Système" + }, + "rows": { + "theme": "Thème", + "language": "Langue", + "provider": "Fournisseur", + "model": "Modèle", + "restart": "Redémarrer nanobot", + "configPath": "Chemin de configuration" + }, + "help": { + "theme": "Basculer entre les apparences claire et sombre.", + "language": "Choisissez la langue utilisée par le WebUI.", + "provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.", + "model": "Définissez le nom du modèle par défaut utilisé par nanobot.", + "configPath": "Le fichier de configuration actuellement utilisé par la passerelle." + }, + "values": { + "light": "Clair", + "dark": "Sombre", + "notAvailable": "Indisponible" + }, + "status": { + "loading": "Chargement des paramètres...", + "loadError": "Impossible de charger les paramètres", + "unsaved": "Modifications non enregistrées.", + "savedRestart": "Enregistré. Redémarrez nanobot pour appliquer." + }, + "actions": { + "save": "Enregistrer", + "saving": "Enregistrement", + "edit": "Modifier", + "cancel": "Annuler" + }, + "byok": { + "description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.", + "configured": "Configuré", + "notConfigured": "Non configuré", + "configuredSection": "Configurés", + "notConfiguredSection": "Non configurés", + "showMore": "Afficher {{count}} de plus", + "showLess": "Afficher moins", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "Saisir l'API key", + "apiKeyConfiguredPlaceholder": "Laisser vide pour conserver la key actuelle", + "configuredKeyHint": "Key configurée", + "apiBasePlaceholder": "Utiliser la valeur par défaut du fournisseur", + "apiKeyRequired": "Une API key est requise pour configurer ce fournisseur.", + "showApiKey": "Afficher l'API key", + "hideApiKey": "Masquer l'API key", + "noConfiguredProviders": "Aucun fournisseur configuré", + "configureFirst": "Configurez d'abord un fournisseur dans BYOK.", + "openByok": "Ouvrir BYOK" + } + }, "chat": { "fallbackTitle": "Discussion {{id}}", "loading": "Chargement…", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 80f72fa3e..dd347d58f 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -12,7 +12,8 @@ "system": { "section": "Sistem", "restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.", - "restart": "Mulai ulang nanobot" + "restart": "Mulai ulang nanobot", + "restarting": "Memulai ulang..." }, "restart": { "completed": "Mulai ulang selesai dalam {{seconds}} dtk." @@ -31,11 +32,81 @@ "newChat": "Obrolan baru", "recent": "Terbaru", "refreshSessions": "Segarkan sesi", + "settings": "Pengaturan", "language": { "label": "Bahasa", "ariaLabel": "Ganti bahasa" } }, + "settings": { + "backToChat": "Kembali ke obrolan", + "sidebar": { + "title": "Pengaturan", + "ariaLabel": "Bagian pengaturan" + }, + "nav": { + "general": "Umum", + "byok": "BYOK" + }, + "sections": { + "interface": "Antarmuka", + "ai": "AI", + "system": "Sistem" + }, + "rows": { + "theme": "Tema", + "language": "Bahasa", + "provider": "Penyedia", + "model": "Model", + "restart": "Mulai ulang nanobot", + "configPath": "Path konfigurasi" + }, + "help": { + "theme": "Beralih antara tampilan terang dan gelap.", + "language": "Pilih bahasa yang digunakan WebUI.", + "provider": "Pilih penyedia untuk permintaan model baru.", + "model": "Atur nama model default yang digunakan nanobot.", + "configPath": "File konfigurasi gateway yang sedang digunakan." + }, + "values": { + "light": "Terang", + "dark": "Gelap", + "notAvailable": "Tidak tersedia" + }, + "status": { + "loading": "Memuat pengaturan...", + "loadError": "Tidak dapat memuat pengaturan", + "unsaved": "Ada perubahan yang belum disimpan.", + "savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan." + }, + "actions": { + "save": "Simpan", + "saving": "Menyimpan", + "edit": "Edit", + "cancel": "Batal" + }, + "byok": { + "description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.", + "configured": "Terkonfigurasi", + "notConfigured": "Belum dikonfigurasi", + "configuredSection": "Terkonfigurasi", + "notConfiguredSection": "Belum dikonfigurasi", + "showMore": "Tampilkan {{count}} lagi", + "showLess": "Tampilkan lebih sedikit", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "Masukkan API key", + "apiKeyConfiguredPlaceholder": "Kosongkan untuk mempertahankan key saat ini", + "configuredKeyHint": "Key terkonfigurasi", + "apiBasePlaceholder": "Gunakan default provider", + "apiKeyRequired": "API key diperlukan untuk mengonfigurasi provider ini.", + "showApiKey": "Tampilkan API key", + "hideApiKey": "Sembunyikan API key", + "noConfiguredProviders": "Belum ada provider terkonfigurasi", + "configureFirst": "Konfigurasikan provider di BYOK terlebih dahulu.", + "openByok": "Buka BYOK" + } + }, "chat": { "fallbackTitle": "Obrolan {{id}}", "loading": "Memuat…", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index dbbd4d994..4cc8a698f 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -12,7 +12,8 @@ "system": { "section": "システム", "restartHint": "実行時の変更を適用するには nanobot を再起動します。", - "restart": "nanobot を再起動" + "restart": "nanobot を再起動", + "restarting": "再起動中..." }, "restart": { "completed": "{{seconds}} 秒で再起動が完了しました。" @@ -31,11 +32,81 @@ "newChat": "新しいチャット", "recent": "最近のチャット", "refreshSessions": "セッションを更新", + "settings": "設定", "language": { "label": "言語", "ariaLabel": "言語を変更" } }, + "settings": { + "backToChat": "チャットに戻る", + "sidebar": { + "title": "設定", + "ariaLabel": "設定セクション" + }, + "nav": { + "general": "一般", + "byok": "BYOK" + }, + "sections": { + "interface": "インターフェース", + "ai": "AI", + "system": "システム" + }, + "rows": { + "theme": "テーマ", + "language": "言語", + "provider": "プロバイダー", + "model": "モデル", + "restart": "nanobot を再起動", + "configPath": "設定パス" + }, + "help": { + "theme": "ライト表示とダーク表示を切り替えます。", + "language": "WebUI で使用する言語を選択します。", + "provider": "新しいモデルリクエストに使うプロバイダーを選択します。", + "model": "nanobot が既定で使用するモデル名を設定します。", + "configPath": "現在ゲートウェイが使用している設定ファイルです。" + }, + "values": { + "light": "ライト", + "dark": "ダーク", + "notAvailable": "利用不可" + }, + "status": { + "loading": "設定を読み込んでいます...", + "loadError": "設定を読み込めませんでした", + "unsaved": "未保存の変更があります。", + "savedRestart": "保存しました。反映するには nanobot を再起動してください。" + }, + "actions": { + "save": "保存", + "saving": "保存中", + "edit": "編集", + "cancel": "キャンセル" + }, + "byok": { + "description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。", + "configured": "設定済み", + "notConfigured": "未設定", + "configuredSection": "設定済み", + "notConfiguredSection": "未設定", + "showMore": "さらに {{count}} 件表示", + "showLess": "折りたたむ", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "API key を入力", + "apiKeyConfiguredPlaceholder": "空欄のままなら現在の key を保持", + "configuredKeyHint": "設定済み key", + "apiBasePlaceholder": "provider の既定値を使用", + "apiKeyRequired": "この provider を設定するには API key が必要です。", + "showApiKey": "API key を表示", + "hideApiKey": "API key を隠す", + "noConfiguredProviders": "設定済み provider がありません", + "configureFirst": "先に BYOK で provider を設定してください。", + "openByok": "BYOK を開く" + } + }, "chat": { "fallbackTitle": "チャット {{id}}", "loading": "読み込み中…", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 8a496f41b..ba08fcc8a 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -12,7 +12,8 @@ "system": { "section": "시스템", "restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.", - "restart": "nanobot 다시 시작" + "restart": "nanobot 다시 시작", + "restarting": "다시 시작 중..." }, "restart": { "completed": "{{seconds}}초 만에 다시 시작되었습니다." @@ -31,11 +32,81 @@ "newChat": "새 채팅", "recent": "최근 대화", "refreshSessions": "세션 새로고침", + "settings": "설정", "language": { "label": "언어", "ariaLabel": "언어 변경" } }, + "settings": { + "backToChat": "채팅으로 돌아가기", + "sidebar": { + "title": "설정", + "ariaLabel": "설정 섹션" + }, + "nav": { + "general": "일반", + "byok": "BYOK" + }, + "sections": { + "interface": "인터페이스", + "ai": "AI", + "system": "시스템" + }, + "rows": { + "theme": "테마", + "language": "언어", + "provider": "제공자", + "model": "모델", + "restart": "nanobot 재시작", + "configPath": "설정 경로" + }, + "help": { + "theme": "밝은 모드와 어두운 모드를 전환합니다.", + "language": "WebUI에서 사용할 언어를 선택합니다.", + "provider": "새 모델 요청에 사용할 제공자를 선택합니다.", + "model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.", + "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다." + }, + "values": { + "light": "라이트", + "dark": "다크", + "notAvailable": "사용할 수 없음" + }, + "status": { + "loading": "설정을 불러오는 중...", + "loadError": "설정을 불러올 수 없습니다", + "unsaved": "저장되지 않은 변경 사항이 있습니다.", + "savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요." + }, + "actions": { + "save": "저장", + "saving": "저장 중", + "edit": "편집", + "cancel": "취소" + }, + "byok": { + "description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.", + "configured": "설정됨", + "notConfigured": "설정 안 됨", + "configuredSection": "설정됨", + "notConfiguredSection": "설정 안 됨", + "showMore": "{{count}}개 더 보기", + "showLess": "접기", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "API key 입력", + "apiKeyConfiguredPlaceholder": "비워 두면 현재 key 유지", + "configuredKeyHint": "설정된 key", + "apiBasePlaceholder": "provider 기본값 사용", + "apiKeyRequired": "이 provider를 설정하려면 API key가 필요합니다.", + "showApiKey": "API key 표시", + "hideApiKey": "API key 숨기기", + "noConfiguredProviders": "설정된 provider가 없습니다", + "configureFirst": "먼저 BYOK에서 provider를 설정하세요.", + "openByok": "BYOK 열기" + } + }, "chat": { "fallbackTitle": "채팅 {{id}}", "loading": "불러오는 중…", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 110d9b9b1..6bc3797f8 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -12,7 +12,8 @@ "system": { "section": "Hệ thống", "restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.", - "restart": "Khởi động lại nanobot" + "restart": "Khởi động lại nanobot", + "restarting": "Đang khởi động lại..." }, "restart": { "completed": "Khởi động lại hoàn tất sau {{seconds}} giây." @@ -31,11 +32,81 @@ "newChat": "Cuộc trò chuyện mới", "recent": "Gần đây", "refreshSessions": "Làm mới phiên", + "settings": "Cài đặt", "language": { "label": "Ngôn ngữ", "ariaLabel": "Đổi ngôn ngữ" } }, + "settings": { + "backToChat": "Quay lại trò chuyện", + "sidebar": { + "title": "Cài đặt", + "ariaLabel": "Các mục cài đặt" + }, + "nav": { + "general": "Chung", + "byok": "BYOK" + }, + "sections": { + "interface": "Giao diện", + "ai": "AI", + "system": "Hệ thống" + }, + "rows": { + "theme": "Giao diện", + "language": "Ngôn ngữ", + "provider": "Nhà cung cấp", + "model": "Mô hình", + "restart": "Khởi động lại nanobot", + "configPath": "Đường dẫn cấu hình" + }, + "help": { + "theme": "Chuyển giữa giao diện sáng và tối.", + "language": "Chọn ngôn ngữ dùng trong WebUI.", + "provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.", + "model": "Đặt tên mô hình mặc định mà nanobot sử dụng.", + "configPath": "Tệp cấu hình gateway hiện đang dùng." + }, + "values": { + "light": "Sáng", + "dark": "Tối", + "notAvailable": "Không khả dụng" + }, + "status": { + "loading": "Đang tải cài đặt...", + "loadError": "Không thể tải cài đặt", + "unsaved": "Có thay đổi chưa lưu.", + "savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng." + }, + "actions": { + "save": "Lưu", + "saving": "Đang lưu", + "edit": "Sửa", + "cancel": "Hủy" + }, + "byok": { + "description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.", + "configured": "Đã cấu hình", + "notConfigured": "Chưa cấu hình", + "configuredSection": "Đã cấu hình", + "notConfiguredSection": "Chưa cấu hình", + "showMore": "Hiển thị thêm {{count}}", + "showLess": "Thu gọn", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "Nhập API key", + "apiKeyConfiguredPlaceholder": "Để trống để giữ key hiện tại", + "configuredKeyHint": "Key đã cấu hình", + "apiBasePlaceholder": "Dùng mặc định của provider", + "apiKeyRequired": "Cần API key để cấu hình provider này.", + "showApiKey": "Hiển thị API key", + "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" + } + }, "chat": { "fallbackTitle": "Trò chuyện {{id}}", "loading": "Đang tải…", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index b4a29741d..d7ccfde19 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -12,7 +12,8 @@ "system": { "section": "系统", "restartHint": "重启 nanobot 以应用运行时更改。", - "restart": "重启 nanobot" + "restart": "重启 nanobot", + "restarting": "正在重启..." }, "restart": { "completed": "重启已完成,用时 {{seconds}} 秒。" @@ -44,6 +45,75 @@ "ariaLabel": "切换语言" } }, + "settings": { + "backToChat": "返回对话", + "sidebar": { + "title": "设置", + "ariaLabel": "设置分区" + }, + "nav": { + "general": "通用", + "byok": "BYOK" + }, + "sections": { + "interface": "界面", + "ai": "AI", + "system": "系统" + }, + "rows": { + "theme": "主题", + "language": "语言", + "provider": "提供商", + "model": "模型", + "restart": "重启 nanobot", + "configPath": "配置路径" + }, + "help": { + "theme": "在浅色和深色外观之间切换。", + "language": "选择 WebUI 使用的语言。", + "provider": "选择新模型请求使用的服务提供商。", + "model": "设置 nanobot 默认使用的模型名称。", + "configPath": "当前网关正在使用的配置文件。" + }, + "values": { + "light": "浅色", + "dark": "深色", + "notAvailable": "不可用" + }, + "status": { + "loading": "正在加载设置...", + "loadError": "无法加载设置", + "unsaved": "有未保存的更改。", + "savedRestart": "已保存。重启 nanobot 后生效。" + }, + "actions": { + "save": "保存", + "saving": "保存中", + "edit": "编辑", + "cancel": "取消" + }, + "byok": { + "description": "自带 provider key。Nanobot 会从当前 config 读取这些值,只有已配置的 provider 才能在通用设置里选择。", + "configured": "已配置", + "notConfigured": "未配置", + "configuredSection": "已配置", + "notConfiguredSection": "未配置", + "showMore": "再显示 {{count}} 个", + "showLess": "收起", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "输入 API key", + "apiKeyConfiguredPlaceholder": "留空则保留当前 key", + "configuredKeyHint": "已配置的 key", + "apiBasePlaceholder": "使用 provider 默认地址", + "apiKeyRequired": "需要 API key 才能配置此 provider。", + "showApiKey": "显示 API key", + "hideApiKey": "隐藏 API key", + "noConfiguredProviders": "没有已配置的 provider", + "configureFirst": "请先在 BYOK 里配置 provider。", + "openByok": "打开 BYOK" + } + }, "chat": { "fallbackTitle": "对话 {{id}}", "loading": "加载中…", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 0a772f741..d498924de 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -12,7 +12,8 @@ "system": { "section": "系統", "restartHint": "重新啟動 nanobot 以套用執行階段變更。", - "restart": "重新啟動 nanobot" + "restart": "重新啟動 nanobot", + "restarting": "正在重新啟動..." }, "restart": { "completed": "重新啟動已完成,耗時 {{seconds}} 秒。" @@ -31,11 +32,81 @@ "newChat": "新增對話", "recent": "最近對話", "refreshSessions": "重新整理會話", + "settings": "設定", "language": { "label": "語言", "ariaLabel": "切換語言" } }, + "settings": { + "backToChat": "返回對話", + "sidebar": { + "title": "設定", + "ariaLabel": "設定分區" + }, + "nav": { + "general": "一般", + "byok": "BYOK" + }, + "sections": { + "interface": "介面", + "ai": "AI", + "system": "系統" + }, + "rows": { + "theme": "主題", + "language": "語言", + "provider": "提供者", + "model": "模型", + "restart": "重新啟動 nanobot", + "configPath": "設定檔路徑" + }, + "help": { + "theme": "在淺色與深色外觀之間切換。", + "language": "選擇 WebUI 使用的語言。", + "provider": "選擇新模型請求使用的服務提供者。", + "model": "設定 nanobot 預設使用的模型名稱。", + "configPath": "目前閘道正在使用的設定檔。" + }, + "values": { + "light": "淺色", + "dark": "深色", + "notAvailable": "不可用" + }, + "status": { + "loading": "正在載入設定...", + "loadError": "無法載入設定", + "unsaved": "有未儲存的變更。", + "savedRestart": "已儲存。重新啟動 nanobot 後生效。" + }, + "actions": { + "save": "儲存", + "saving": "儲存中", + "edit": "編輯", + "cancel": "取消" + }, + "byok": { + "description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。", + "configured": "已設定", + "notConfigured": "未設定", + "configuredSection": "已設定", + "notConfiguredSection": "未設定", + "showMore": "再顯示 {{count}} 個", + "showLess": "收合", + "apiKey": "API key", + "apiBase": "API base", + "apiKeyPlaceholder": "輸入 API key", + "apiKeyConfiguredPlaceholder": "留空則保留目前 key", + "configuredKeyHint": "已設定的 key", + "apiBasePlaceholder": "使用 provider 預設地址", + "apiKeyRequired": "需要 API key 才能設定此 provider。", + "showApiKey": "顯示 API key", + "hideApiKey": "隱藏 API key", + "noConfiguredProviders": "沒有已設定的 provider", + "configureFirst": "請先在 BYOK 設定 provider。", + "openByok": "開啟 BYOK" + } + }, "chat": { "fallbackTitle": "對話 {{id}}", "loading": "載入中…", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 4f3da2aef..4658c4673 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,4 +1,10 @@ -import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types"; +import type { + ChatSummary, + ProviderSettingsUpdate, + SettingsPayload, + SettingsUpdate, + SlashCommand, +} from "./types"; export class ApiError extends Error { status: number; @@ -147,3 +153,18 @@ export async function updateSettings( if (update.provider !== undefined) query.set("provider", update.provider); return request(`${base}/api/settings/update?${query}`, token); } + +export async function updateProviderSettings( + token: string, + update: ProviderSettingsUpdate, + base: string = "", +): Promise { + const query = new URLSearchParams(); + query.set("provider", update.provider); + if (update.apiKey !== undefined) query.set("api_key", update.apiKey); + if (update.apiBase !== undefined) query.set("api_base", update.apiBase); + return request( + `${base}/api/settings/provider/update?${query}`, + token, + ); +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index bf757ec56..9d5871f5d 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -77,6 +77,10 @@ export interface SettingsPayload { providers: Array<{ name: string; label: string; + configured: boolean; + api_key_hint?: string | null; + api_base?: string | null; + default_api_base?: string | null; }>; runtime: { config_path: string; @@ -89,6 +93,12 @@ export interface SettingsUpdate { provider?: string; } +export interface ProviderSettingsUpdate { + provider: string; + apiKey?: string; + apiBase?: string; +} + export interface SlashCommand { command: string; title: string; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index ceed3d687..6d75cb960 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -5,6 +5,7 @@ import { fetchSessionMessages, listSessions, listSlashCommands, + updateProviderSettings, updateSettings, } from "@/lib/api"; @@ -55,6 +56,21 @@ describe("webui API helpers", () => { ); }); + it("serializes provider settings updates without returning secrets", async () => { + await updateProviderSettings("tok", { + provider: "openrouter", + apiKey: "sk-or-test", + apiBase: "https://openrouter.ai/api/v1", + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/settings/provider/update?provider=openrouter&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + it("maps generated session titles from the sessions list", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 3f32970fa..89d41c26a 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -155,7 +155,7 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); - it("opens the Cursor-style settings view from the header", async () => { + it("opens the settings view from the sidebar footer", async () => { mockSessions = [ { key: "websocket:chat-a", @@ -181,8 +181,13 @@ describe("App layout", () => { has_api_key: true, }, providers: [ - { name: "auto", label: "Auto" }, - { name: "openai", label: "OpenAI" }, + { name: "openai", label: "OpenAI", configured: true }, + { + name: "openrouter", + label: "OpenRouter", + configured: false, + default_api_base: "https://openrouter.ai/api/v1", + }, ], runtime: { config_path: "/tmp/config.json", @@ -198,11 +203,24 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Open settings" })); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(document.title).toBe("Settings · nanobot"); + expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument(); + const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); + expect(within(settingsNav).getByRole("button", { name: "General" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(within(settingsNav).getByRole("button", { name: "BYOK" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); + fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" })); + expect(screen.getByText("OpenRouter")).toBeInTheDocument(); + expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); }); it("filters sidebar sessions through the lightweight search row", async () => { @@ -285,7 +303,7 @@ describe("App layout", () => { expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument(); + expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument(); expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument(); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 85a7c21dc..031b1aeea 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -8,6 +8,7 @@ import { resources } from "@/i18n"; const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"]; +const SETTINGS_NAV_KEYS = ["general", "byok"]; describe("webui i18n", () => { it("switches UI copy and document locale through the language switcher", async () => { @@ -62,4 +63,28 @@ describe("webui i18n", () => { } } }); + + it("keeps settings navigation localized for every registered locale", () => { + for (const resource of Object.values(resources)) { + const common = resource.common; + expect(common.app.system.restarting).toBeTruthy(); + expect(common.sidebar.settings).toBeTruthy(); + expect(common.settings.sidebar.title).toBeTruthy(); + expect(common.settings.backToChat).toBeTruthy(); + for (const key of SETTINGS_NAV_KEYS) { + expect(common.settings.nav[key as keyof typeof common.settings.nav]).toBeTruthy(); + } + expect(common.settings.rows.theme).toBeTruthy(); + expect(common.settings.status.loading).toBeTruthy(); + expect(common.settings.actions.save).toBeTruthy(); + expect(common.settings.actions.edit).toBeTruthy(); + expect(common.settings.byok.configured).toBeTruthy(); + expect(common.settings.byok.configuredSection).toBeTruthy(); + expect(common.settings.byok.showMore).toBeTruthy(); + expect(common.settings.byok.apiKeyRequired).toBeTruthy(); + expect(common.settings.byok.showApiKey).toBeTruthy(); + expect(common.settings.byok.hideApiKey).toBeTruthy(); + expect(common.settings.byok.configuredKeyHint).toBeTruthy(); + } + }); });