feat(webui): redesign settings and BYOK configuration

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-08 15:31:52 +00:00 committed by Xubin Ren
parent 451d740849
commit 2cc32ca07c
25 changed files with 1679 additions and 216 deletions

View File

@ -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.""" """Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query) parsed = urlparse("ws://x" + path_with_query)
path = _strip_trailing_slash(parsed.path or "/") 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: 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 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: def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content.""" """Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip() text = raw.strip()
@ -560,6 +568,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/update": if got == "/api/settings/update":
return self._handle_settings_update(request) 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) m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m: if m:
return self._handle_session_messages(request, m.group(1)) return self._handle_session_messages(request, m.group(1))
@ -688,6 +699,21 @@ class WebSocketChannel(BaseChannel):
if defaults.provider != "auto": if defaults.provider != "auto":
spec = find_by_name(defaults.provider) spec = find_by_name(defaults.provider)
selected_provider = spec.name if spec else provider_name 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 { return {
"agent": { "agent": {
"model": defaults.model, "model": defaults.model,
@ -695,12 +721,7 @@ class WebSocketChannel(BaseChannel):
"resolved_provider": provider_name, "resolved_provider": provider_name,
"has_api_key": bool(provider and provider.api_key), "has_api_key": bool(provider and provider.api_key),
}, },
"providers": [ "providers": providers,
{"name": "auto", "label": "Auto"}
] + [
{"name": spec.name, "label": spec.label}
for spec in PROVIDERS
],
"runtime": { "runtime": {
"config_path": str(get_config_path().expanduser()), "config_path": str(get_config_path().expanduser()),
}, },
@ -739,16 +760,66 @@ class WebSocketChannel(BaseChannel):
provider = _query_first(query, "provider") provider = _query_first(query, "provider")
if provider is not None: if provider is not None:
provider = provider.strip() or "auto" provider = provider.strip()
if provider != "auto" and find_by_name(provider) is None: if not provider:
return _http_error(400, "provider is required")
if find_by_name(provider) is None:
return _http_error(400, "unknown provider") 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: if defaults.provider != provider:
defaults.provider = provider defaults.provider = provider
changed = True changed = True
if changed: if changed:
save_config(config) 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 @staticmethod
def _is_webui_session_key(key: str) -> bool: def _is_webui_session_key(key: str) -> bool:

View File

@ -542,10 +542,26 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
body = settings.json() body = settings.json()
assert body["agent"]["model"] == "openai/gpt-4o" assert body["agent"]["model"] == "openai/gpt-4o"
assert body["agent"]["provider"] == "openai" 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 body["agent"]["has_api_key"] is True
assert "secret-key" not in settings.text 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( updated = await _http_get(
"http://127.0.0.1:" "http://127.0.0.1:"
f"{port}/api/settings/update?model=openrouter/test" 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"}, headers={"Authorization": "Bearer tok"},
) )
assert updated.status_code == 200 assert updated.status_code == 200
assert updated.json()["requires_restart"] is True assert updated.json()["requires_restart"] is False
saved = load_config(config_path) saved = load_config(config_path)
assert saved.agents.defaults.model == "openrouter/test" assert saved.agents.defaults.model == "openrouter/test"
assert saved.agents.defaults.provider == "openrouter" assert saved.agents.defaults.provider == "openrouter"
assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
finally: finally:
await channel.stop() await channel.stop()
await server_task await server_task

View File

@ -253,6 +253,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
const lastSessionsLen = useRef(0); const lastSessionsLen = useRef(0);
const restartSawDisconnectRef = useRef(false); const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null); const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
useEffect(() => { useEffect(() => {
try { try {
@ -334,6 +335,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
const chatId = activeSession?.chatId ?? client.defaultChatId; const chatId = activeSession?.chatId ?? client.defaultChatId;
if (!chatId) return; if (!chatId) return;
restartSawDisconnectRef.current = false; restartSawDisconnectRef.current = false;
setIsRestarting(true);
try { try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now())); window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch { } catch {
@ -362,6 +364,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
} catch { } catch {
// ignore storage errors // ignore storage errors
} }
setIsRestarting(false);
setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) })); setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) }));
window.setTimeout(() => setRestartToast(null), 3_500); window.setTimeout(() => setRestartToast(null), 3_500);
}); });
@ -396,10 +399,16 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
: t("app.brand"); : t("app.brand");
useEffect(() => { useEffect(() => {
if (view === "settings") {
document.title = t("app.documentTitle.chat", {
title: t("settings.sidebar.title"),
});
return;
}
document.title = activeSession document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle }) ? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base"); : t("app.documentTitle.base");
}, [activeSession, headerTitle, i18n.resolvedLanguage, t]); }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = { const sidebarProps = {
sessions, sessions,
@ -409,11 +418,14 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onSelect: onSelectChat, onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) => onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }), setPendingDelete({ key, label }),
onOpenSettings,
}; };
const showMainSidebar = view !== "settings";
return ( return (
<div className="relative flex h-full w-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} {/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside <aside
className={cn( className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block", "relative z-20 hidden shrink-0 overflow-hidden lg:block",
@ -432,7 +444,9 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} /> <Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div> </div>
</aside> </aside>
) : null}
{showMainSidebar ? (
<Sheet <Sheet
open={mobileSidebarOpen} open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)} onOpenChange={(open) => setMobileSidebarOpen(open)}
@ -446,6 +460,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} /> <Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) : null}
<main className="flex h-full min-w-0 flex-1 flex-col"> <main className="flex h-full min-w-0 flex-1 flex-col">
{view === "settings" ? ( {view === "settings" ? (
@ -456,6 +471,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onModelNameChange={onModelNameChange} onModelNameChange={onModelNameChange}
onLogout={onLogout} onLogout={onLogout}
onRestart={onRestart} onRestart={onRestart}
isRestarting={isRestarting}
/> />
) : ( ) : (
<ThreadShell <ThreadShell
@ -467,7 +483,6 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onTurnEnd={onTurnEnd} onTurnEnd={onTurnEnd}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen} hideSidebarToggleOnDesktop={desktopSidebarOpen}
/> />
)} )}

View File

@ -24,9 +24,9 @@ export default function MarkdownTextRenderer({
return ( return (
<div <div
className={cn( className={cn(
"markdown-content prose prose-lg max-w-none dark:prose-invert", "markdown-content prose max-w-none dark:prose-invert",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight", "prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
"prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm", "prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-[13px]",
"prose-p:my-2", "prose-p:my-2",
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5", "prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal", "prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",

View File

@ -73,7 +73,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<p <p
className={cn( className={cn(
"ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2", "ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2",
"text-left text-[18px]/[1.8] whitespace-pre-wrap break-words", "text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)} )}
> >
{message.content} {message.content}
@ -87,7 +87,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const media = message.media ?? []; const media = message.media ?? [];
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
return ( return (
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}> <div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{empty && message.isStreaming ? ( {empty && message.isStreaming ? (
<TypingDots /> <TypingDots />
) : ( ) : (

View File

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { import {
Menu, Menu,
Search, Search,
Settings,
SquarePen, SquarePen,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -20,6 +21,7 @@ interface SidebarProps {
onNewChat: () => void; onNewChat: () => void;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
onOpenSettings: () => void;
onCollapse: () => void; onCollapse: () => void;
} }
@ -113,7 +115,16 @@ export function Sidebar(props: SidebarProps) {
/> />
</div> </div>
<Separator className="bg-sidebar-border/50" /> <Separator className="bg-sidebar-border/50" />
<div className="flex items-center px-2.5 py-2.5 text-xs"> <div className="space-y-1 px-2.5 py-2.5 text-xs">
<Button
type="button"
variant="ghost"
onClick={props.onOpenSettings}
className="h-8 w-full justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.settings")}
</Button>
<ConnectionBadge /> <ConnectionBadge />
</div> </div>
</nav> </nav>

View File

@ -1,15 +1,51 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import { ChevronLeft, Loader2 } from "lucide-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 { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; 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 { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types"; import type { SettingsPayload } from "@/lib/types";
type SettingsSectionKey = "general" | "byok";
interface SettingsViewProps { interface SettingsViewProps {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
@ -17,22 +53,33 @@ interface SettingsViewProps {
onModelNameChange: (modelName: string | null) => void; onModelNameChange: (modelName: string | null) => void;
onLogout?: () => void; onLogout?: () => void;
onRestart?: () => void; onRestart?: () => void;
isRestarting?: boolean;
} }
export function SettingsView({ export function SettingsView({
theme,
onToggleTheme,
onBackToChat, onBackToChat,
onModelNameChange, onModelNameChange,
onLogout, onLogout,
onRestart, onRestart,
isRestarting = false,
}: SettingsViewProps) { }: SettingsViewProps) {
const { t } = useTranslation();
const { token } = useClient(); const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null); const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); 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 [form, setForm] = useState({ const [form, setForm] = useState({
model: "", model: "",
provider: "auto", provider: "",
}); });
const applyPayload = useCallback((payload: SettingsPayload) => { const applyPayload = useCallback((payload: SettingsPayload) => {
@ -64,6 +111,20 @@ export function SettingsView({
}; };
}, [applyPayload, token]); }, [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(() => { const dirty = useMemo(() => {
if (!settings) return false; if (!settings) return false;
return ( return (
@ -76,7 +137,10 @@ export function SettingsView({
if (!dirty || saving) return; if (!dirty || saving) return;
setSaving(true); setSaving(true);
try { try {
const payload = await updateSettings(token, form); const payload = await updateSettings(token, {
model: form.model,
...(form.provider ? { provider: form.provider } : {}),
});
applyPayload(payload); applyPayload(payload);
onModelNameChange(payload.agent.model || null); onModelNameChange(payload.agent.model || null);
setError(null); setError(null);
@ -87,63 +151,242 @@ export function SettingsView({
} }
}; };
return ( const saveProvider = async (providerName: string) => {
<div className="min-h-0 flex-1 overflow-y-auto bg-background"> if (providerSaving) return;
<main className="mx-auto w-full max-w-[1000px] px-6 py-6"> const provider = settings?.providers.find((item) => item.name === providerName);
<button if (!provider) return;
type="button" const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
onClick={onBackToChat} const apiKey = providerForm.apiKey.trim();
className="mb-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground" if (!provider.configured && !apiKey) {
> setError(t("settings.byok.apiKeyRequired"));
<ChevronLeft className="h-3.5 w-3.5" /> return;
Back to chat }
</button> 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);
}
};
<h1 className="mb-6 text-base font-semibold tracking-tight">General</h1> 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 (
<div className="flex min-h-0 flex-1 overflow-hidden bg-[radial-gradient(circle_at_50%_0%,hsl(var(--muted))_0%,hsl(var(--background))_42%)]">
<SettingsSidebar
activeSection={activeSection}
onSelectSection={setActiveSection}
onBackToChat={onBackToChat}
onLogout={onLogout}
/>
<main className="min-w-0 flex-1 overflow-y-auto">
<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">
{t("settings.sidebar.title")}
</p>
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.035em] text-foreground sm:text-[34px]">
{t(`settings.nav.${activeSection}`)}
</h1>
</div>
{loading ? ( {loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground"> <div className="flex h-48 items-center justify-center rounded-[24px] border border-border/50 bg-card/75 text-sm text-muted-foreground shadow-[0_20px_70px_rgba(15,23,42,0.07)]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading settings... {t("settings.status.loading")}
</div> </div>
) : error ? ( ) : error && !settings ? (
<SettingsGroup> <SettingsGroup>
<SettingsRow title="Could not load settings"> <SettingsRow title={t("settings.status.loadError")}>
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span> <span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow> </SettingsRow>
</SettingsGroup> </SettingsGroup>
) : settings ? ( ) : settings ? (
<SettingsSection <div className="space-y-5">
{error ? (
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
{activeSection === "general" ? (
<GeneralSettings
theme={theme}
onToggleTheme={onToggleTheme}
form={form} form={form}
setForm={setForm} setForm={setForm}
settings={settings} settings={settings}
dirty={dirty} dirty={dirty}
saving={saving} saving={saving}
onSave={save} onSave={save}
onLogout={onLogout}
onRestart={onRestart} onRestart={onRestart}
isRestarting={isRestarting}
onOpenByok={() => setActiveSection("byok")}
/> />
) : (
<ByokSettings
settings={settings}
expandedProvider={expandedProvider}
providerForms={providerForms}
visibleProviderKeys={visibleProviderKeys}
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
onToggleProvider={(provider) =>
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}
/>
)}
</div>
) : null} ) : null}
</div>
</main> </main>
</div> </div>
); );
} }
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 (
<aside className="flex w-[17rem] shrink-0 flex-col border-r border-border/55 bg-card/62 px-3 py-4 shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none">
<button
type="button"
onClick={onBackToChat}
className="mb-3 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
<div className="mb-5 px-2">
<h2 className="text-[21px] font-semibold tracking-[-0.035em] text-foreground">
{t("settings.sidebar.title")}
</h2>
</div>
<nav aria-label={t("settings.sidebar.ariaLabel")} className="space-y-1">
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon }) => {
const active = key === activeSection;
return (
<button
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
active
? "bg-muted/90 text-foreground shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">{t(`settings.nav.${key}`)}</span>
</button>
);
})}
</nav>
<div className="mt-auto pt-4">
{onLogout ? (
<Button
type="button"
variant="ghost"
onClick={onLogout}
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
>
<LogOut className="h-4 w-4" aria-hidden />
{t("app.account.logout")}
</Button>
) : null}
</div>
</aside>
);
}
function GeneralSettings({
theme,
onToggleTheme,
form, form,
setForm, setForm,
settings, settings,
dirty, dirty,
saving, saving,
onSave, onSave,
onLogout,
onRestart, onRestart,
isRestarting,
onOpenByok,
}: { }: {
theme: "light" | "dark";
onToggleTheme: () => void;
form: { form: {
model: string; model: string;
provider: string; provider: string;
}; };
setForm: React.Dispatch<React.SetStateAction<{ setForm: Dispatch<SetStateAction<{
model: string; model: string;
provider: string; provider: string;
}>>; }>>;
@ -151,37 +394,80 @@ function SettingsSection({
dirty: boolean; dirty: boolean;
saving: boolean; saving: boolean;
onSave: () => void; onSave: () => void;
onLogout?: () => void;
onRestart?: () => void; onRestart?: () => void;
isRestarting?: boolean;
onOpenByok: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const providerValue = configuredProviders.some((provider) => provider.name === form.provider)
? form.provider
: "";
return ( return (
<div className="space-y-7"> <div className="space-y-8">
<section> <section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">AI</h2> <SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup> <SettingsGroup>
<SettingsRow title="Provider"> <SettingsRow
<select title={t("settings.rows.theme")}
value={form.provider} description={t("settings.help.theme")}
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))} >
<button
type="button"
onClick={onToggleTheme}
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
>
<span
className={cn( className={cn(
"h-8 w-[210px] rounded-md border border-input bg-background px-2 text-sm", "rounded-full px-3 py-1 transition-colors",
"outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", theme === "light" && "bg-background text-foreground shadow-sm",
)} )}
> >
{settings.providers.map((provider) => ( {t("settings.values.light")}
<option key={provider.name} value={provider.name}> </span>
{provider.label} <span
</option> className={cn(
))} "rounded-full px-3 py-1 transition-colors",
</select> theme === "dark" && "bg-background text-foreground shadow-sm",
)}
>
{t("settings.values.dark")}
</span>
</button>
</SettingsRow> </SettingsRow>
<SettingsRow title="Model"> <SettingsRow
title={t("settings.rows.language")}
description={t("settings.help.language")}
>
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{t("settings.sections.ai")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={t("settings.rows.provider")}
description={t("settings.help.provider")}
>
<ProviderPicker
providers={configuredProviders}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
/>
</SettingsRow>
<SettingsRow
title={t("settings.rows.model")}
description={t("settings.help.model")}
>
<Input <Input
value={form.model} value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[280px]" className="h-8 w-[280px] rounded-full text-[13px]"
/> />
</SettingsRow> </SettingsRow>
@ -193,39 +479,46 @@ function SettingsSection({
onSave={onSave} onSave={onSave}
/> />
) : null} ) : null}
</SettingsGroup> {configuredProviders.length === 0 ? (
</section> <SettingsRow title={t("settings.byok.configureFirst")}>
<Button size="sm" variant="outline" onClick={onOpenByok} className="rounded-full">
<section> {t("settings.byok.openByok")}
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">Interface</h2> </Button>
<SettingsGroup>
<SettingsRow title="Language">
<LanguageSwitcher />
</SettingsRow> </SettingsRow>
) : null}
</SettingsGroup> </SettingsGroup>
</section> </section>
{onRestart && ( {onRestart && (
<section> <section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.system.section")}</h2> <SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup> <SettingsGroup>
<SettingsRow title={t("app.system.restartHint")}> <SettingsRow
<Button size="sm" variant="outline" onClick={onRestart}> title={t("settings.rows.restart")}
{t("app.system.restart")} description={t("app.system.restartHint")}
>
<Button
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button> </Button>
</SettingsRow> </SettingsRow>
</SettingsGroup> <SettingsRow
</section> title={t("settings.rows.configPath")}
)} description={t("settings.help.configPath")}
>
{onLogout && ( <span className="max-w-[260px] truncate text-right text-[13px] text-muted-foreground">
<section> {settings.runtime.config_path || t("settings.values.notAvailable")}
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2> </span>
<SettingsGroup>
<SettingsRow title={t("app.account.logoutHint")}>
<Button size="sm" variant="outline" onClick={onLogout}>
{t("app.account.logout")}
</Button>
</SettingsRow> </SettingsRow>
</SettingsGroup> </SettingsGroup>
</section> </section>
@ -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 ( return (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80"> <DropdownMenu>
<div className="divide-y divide-border/50">{children}</div> <DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn(
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
disabled && "text-muted-foreground",
)}
>
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
>
{providers.map((provider) => {
const selected = provider.name === value;
return (
<DropdownMenuItem
key={provider.name}
onSelect={() => 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",
)}
>
<span className="truncate">{provider.label}</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function ByokSettings({
settings,
expandedProvider,
providerForms,
visibleProviderKeys,
editingProviderKeys,
providerSaving,
onToggleProvider,
onToggleProviderKey,
onToggleProviderKeyEditing,
onChangeProviderForm,
onSaveProvider,
}: {
settings: SettingsPayload;
expandedProvider: string | null;
providerForms: Record<string, { apiKey: string; apiBase: string }>;
visibleProviderKeys: Record<string, boolean>;
editingProviderKeys: Record<string, boolean>;
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 (
<div
key={provider.name}
className="divide-y divide-border/45"
>
<button
type="button"
onClick={() => onToggleProvider(provider.name)}
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
>
<span className="flex min-w-0 items-center gap-3">
<ProviderIcon provider={provider.name} />
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
{provider.label}
</span>
</span>
</span>
<span
className={cn(
"rounded-full px-2.5 py-1 text-[12px] font-medium",
provider.configured
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{provider.configured
? t("settings.byok.configured")
: t("settings.byok.notConfigured")}
</span>
</button>
{expanded ? (
<div className="space-y-3 bg-muted/18 px-4 py-4 sm:px-5">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.byok.apiKey")}
</span>
<div className="relative">
{editingKey ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey}
onChange={(event) =>
onChangeProviderForm(provider.name, { apiKey: event.target.value })
}
placeholder={
provider.configured
? 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={() => onToggleProviderKey(provider.name)}
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">
{provider.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onToggleProviderKeyEditing(provider.name)}
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>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.byok.apiBase")}
</span>
<Input
value={form.apiBase}
onChange={(event) =>
onChangeProviderForm(provider.name, { apiBase: event.target.value })
}
placeholder={provider.default_api_base ?? t("settings.byok.apiBasePlaceholder")}
className="h-9 rounded-full text-[13px]"
/>
</label>
<div className="flex items-center justify-end">
<Button
size="sm"
variant="outline"
onClick={() => onSaveProvider(provider.name)}
disabled={saving || (!provider.configured && !form.apiKey.trim())}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</div>
) : null}
</div>
);
};
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
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>
</div>
);
}
function ByokSectionHeader({ title, count }: { title: string; count: number }) {
return (
<div className="flex items-center justify-between px-1">
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{title}
</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11.5px] font-medium text-muted-foreground">
{count}
</span>
</div>
);
}
function ByokEmptyState({ children }: { children: ReactNode }) {
return (
<div className="rounded-[18px] border border-dashed border-border/65 bg-card/45 px-4 py-5 text-[13px] text-muted-foreground">
{children}
</div>
);
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
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 (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
</span>
);
}
function SettingsSectionTitle({ children }: { children: ReactNode }) {
return (
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{children}
</h2>
);
}
function SettingsGroup({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
<div className="divide-y divide-border/45">{children}</div>
</div> </div>
); );
} }
function SettingsRow({ function SettingsRow({
title, title,
description,
children, children,
}: { }: {
title: string; title: string;
children?: React.ReactNode; description?: string;
children?: ReactNode;
}) { }) {
return ( return (
<div className="flex min-h-[52px] flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between"> <div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium leading-5">{title}</div> <div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
{description ? (
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
{description}
</div>
) : null}
</div> </div>
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null} {children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
</div> </div>
@ -270,13 +915,14 @@ function SettingsFooter({
saved: boolean; saved: boolean;
onSave: () => void; onSave: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="flex min-h-[52px] items-center justify-between gap-4 px-3 py-2.5"> <div className="flex min-h-[58px] items-center justify-between gap-4 px-4 py-3 sm:px-5">
<div className="text-sm text-muted-foreground"> <div className="text-[13px] text-muted-foreground">
{saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."} {saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")}
</div> </div>
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving}> <Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving} className="rounded-full">
{saving ? "Saving" : "Save"} {saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button> </Button>
</div> </div>
); );

View File

@ -49,7 +49,7 @@ export function AskUserPrompt({
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary"> <div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
<MessageSquareText className="h-3.5 w-3.5" aria-hidden /> <MessageSquareText className="h-3.5 w-3.5" aria-hidden />
</div> </div>
<p className="min-w-0 flex-1 text-sm font-medium leading-5 text-foreground"> <p className="min-w-0 flex-1 text-[13.5px] font-medium leading-5 text-foreground">
{question} {question}
</p> </p>
</div> </div>
@ -94,7 +94,7 @@ export function AskUserPrompt({
placeholder="Type your own answer..." placeholder="Type your own answer..."
className={cn( className={cn(
"min-h-9 flex-1 resize-none rounded-[10px] border border-border/70 bg-background", "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", "focus-visible:ring-1 focus-visible:ring-primary/40",
)} )}
/> />

View File

@ -473,8 +473,8 @@ export function ThreadComposer({
className={cn( className={cn(
"w-full resize-none bg-transparent", "w-full resize-none bg-transparent",
isHero isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6" ? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", : "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
"placeholder:text-muted-foreground/70", "placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none", "focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",

View File

@ -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 { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -9,7 +9,6 @@ interface ThreadHeaderProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
onOpenSettings: () => void;
hideSidebarToggleOnDesktop?: boolean; hideSidebarToggleOnDesktop?: boolean;
minimal?: boolean; minimal?: boolean;
} }
@ -19,7 +18,6 @@ export function ThreadHeader({
onToggleSidebar, onToggleSidebar,
theme, theme,
onToggleTheme, onToggleTheme,
onOpenSettings,
hideSidebarToggleOnDesktop = false, hideSidebarToggleOnDesktop = false,
minimal = false, minimal = false,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
@ -39,30 +37,7 @@ export function ThreadHeader({
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
<div className="flex items-center gap-0.5"> <ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div> </div>
); );
} }
@ -87,11 +62,27 @@ export function ThreadHeader({
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5"> <ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div>
);
}
function ThemeButton({
theme,
onToggleTheme,
label,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
label: string;
}) {
return (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={t("thread.header.toggleTheme")} aria-label={label}
onClick={onToggleTheme} onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground" className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
> >
@ -101,18 +92,5 @@ export function ThreadHeader({
<Moon className="h-4 w-4" /> <Moon className="h-4 w-4" />
)} )}
</Button> </Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div>
); );
} }

View File

@ -34,7 +34,6 @@ interface ThreadShellProps {
onTurnEnd?: () => void; onTurnEnd?: () => void;
theme?: "light" | "dark"; theme?: "light" | "dark";
onToggleTheme?: () => void; onToggleTheme?: () => void;
onOpenSettings?: () => void;
hideSidebarToggleOnDesktop?: boolean; hideSidebarToggleOnDesktop?: boolean;
} }
@ -78,7 +77,6 @@ export function ThreadShell({
onTurnEnd, onTurnEnd,
theme = "light", theme = "light",
onToggleTheme = () => {}, onToggleTheme = () => {},
onOpenSettings = () => {},
hideSidebarToggleOnDesktop = false, hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -312,7 +310,6 @@ export function ThreadShell({
onToggleSidebar={onToggleSidebar} onToggleSidebar={onToggleSidebar}
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop} hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading} minimal={!session && !loading}
/> />

View File

@ -24,7 +24,8 @@
"system": { "system": {
"section": "System", "section": "System",
"restartHint": "Restart nanobot to apply runtime changes.", "restartHint": "Restart nanobot to apply runtime changes.",
"restart": "Restart nanobot" "restart": "Restart nanobot",
"restarting": "Restarting..."
}, },
"restart": { "restart": {
"completed": "Restart completed in {{seconds}}s." "completed": "Restart completed in {{seconds}}s."
@ -56,6 +57,75 @@
"ariaLabel": "Change language" "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": { "chat": {
"fallbackTitle": "Chat {{id}}", "fallbackTitle": "Chat {{id}}",
"loading": "Loading…", "loading": "Loading…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "Sistema", "section": "Sistema",
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.", "restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
"restart": "Reiniciar nanobot" "restart": "Reiniciar nanobot",
"restarting": "Reiniciando..."
}, },
"restart": { "restart": {
"completed": "Reinicio completado en {{seconds}} s." "completed": "Reinicio completado en {{seconds}} s."
@ -31,11 +32,81 @@
"newChat": "Nuevo chat", "newChat": "Nuevo chat",
"recent": "Recientes", "recent": "Recientes",
"refreshSessions": "Actualizar sesiones", "refreshSessions": "Actualizar sesiones",
"settings": "Configuración",
"language": { "language": {
"label": "Idioma", "label": "Idioma",
"ariaLabel": "Cambiar 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": { "chat": {
"fallbackTitle": "Chat {{id}}", "fallbackTitle": "Chat {{id}}",
"loading": "Cargando…", "loading": "Cargando…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "Système", "section": "Système",
"restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.", "restartHint": "Redémarrez nanobot pour appliquer les changements dexécution.",
"restart": "Redémarrer nanobot" "restart": "Redémarrer nanobot",
"restarting": "Redémarrage..."
}, },
"restart": { "restart": {
"completed": "Redémarrage terminé en {{seconds}} s." "completed": "Redémarrage terminé en {{seconds}} s."
@ -31,11 +32,81 @@
"newChat": "Nouvelle discussion", "newChat": "Nouvelle discussion",
"recent": "Récentes", "recent": "Récentes",
"refreshSessions": "Actualiser les sessions", "refreshSessions": "Actualiser les sessions",
"settings": "Paramètres",
"language": { "language": {
"label": "Langue", "label": "Langue",
"ariaLabel": "Changer de 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": { "chat": {
"fallbackTitle": "Discussion {{id}}", "fallbackTitle": "Discussion {{id}}",
"loading": "Chargement…", "loading": "Chargement…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "Sistem", "section": "Sistem",
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.", "restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
"restart": "Mulai ulang nanobot" "restart": "Mulai ulang nanobot",
"restarting": "Memulai ulang..."
}, },
"restart": { "restart": {
"completed": "Mulai ulang selesai dalam {{seconds}} dtk." "completed": "Mulai ulang selesai dalam {{seconds}} dtk."
@ -31,11 +32,81 @@
"newChat": "Obrolan baru", "newChat": "Obrolan baru",
"recent": "Terbaru", "recent": "Terbaru",
"refreshSessions": "Segarkan sesi", "refreshSessions": "Segarkan sesi",
"settings": "Pengaturan",
"language": { "language": {
"label": "Bahasa", "label": "Bahasa",
"ariaLabel": "Ganti 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": { "chat": {
"fallbackTitle": "Obrolan {{id}}", "fallbackTitle": "Obrolan {{id}}",
"loading": "Memuat…", "loading": "Memuat…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "システム", "section": "システム",
"restartHint": "実行時の変更を適用するには nanobot を再起動します。", "restartHint": "実行時の変更を適用するには nanobot を再起動します。",
"restart": "nanobot を再起動" "restart": "nanobot を再起動",
"restarting": "再起動中..."
}, },
"restart": { "restart": {
"completed": "{{seconds}} 秒で再起動が完了しました。" "completed": "{{seconds}} 秒で再起動が完了しました。"
@ -31,11 +32,81 @@
"newChat": "新しいチャット", "newChat": "新しいチャット",
"recent": "最近のチャット", "recent": "最近のチャット",
"refreshSessions": "セッションを更新", "refreshSessions": "セッションを更新",
"settings": "設定",
"language": { "language": {
"label": "言語", "label": "言語",
"ariaLabel": "言語を変更" "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": { "chat": {
"fallbackTitle": "チャット {{id}}", "fallbackTitle": "チャット {{id}}",
"loading": "読み込み中…", "loading": "読み込み中…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "시스템", "section": "시스템",
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.", "restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
"restart": "nanobot 다시 시작" "restart": "nanobot 다시 시작",
"restarting": "다시 시작 중..."
}, },
"restart": { "restart": {
"completed": "{{seconds}}초 만에 다시 시작되었습니다." "completed": "{{seconds}}초 만에 다시 시작되었습니다."
@ -31,11 +32,81 @@
"newChat": "새 채팅", "newChat": "새 채팅",
"recent": "최근 대화", "recent": "최근 대화",
"refreshSessions": "세션 새로고침", "refreshSessions": "세션 새로고침",
"settings": "설정",
"language": { "language": {
"label": "언어", "label": "언어",
"ariaLabel": "언어 변경" "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": { "chat": {
"fallbackTitle": "채팅 {{id}}", "fallbackTitle": "채팅 {{id}}",
"loading": "불러오는 중…", "loading": "불러오는 중…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "Hệ thống", "section": "Hệ thống",
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.", "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": { "restart": {
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây." "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", "newChat": "Cuộc trò chuyện mới",
"recent": "Gần đây", "recent": "Gần đây",
"refreshSessions": "Làm mới phiên", "refreshSessions": "Làm mới phiên",
"settings": "Cài đặt",
"language": { "language": {
"label": "Ngôn ngữ", "label": "Ngôn ngữ",
"ariaLabel": "Đổi 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": { "chat": {
"fallbackTitle": "Trò chuyện {{id}}", "fallbackTitle": "Trò chuyện {{id}}",
"loading": "Đang tải…", "loading": "Đang tải…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "系统", "section": "系统",
"restartHint": "重启 nanobot 以应用运行时更改。", "restartHint": "重启 nanobot 以应用运行时更改。",
"restart": "重启 nanobot" "restart": "重启 nanobot",
"restarting": "正在重启..."
}, },
"restart": { "restart": {
"completed": "重启已完成,用时 {{seconds}} 秒。" "completed": "重启已完成,用时 {{seconds}} 秒。"
@ -44,6 +45,75 @@
"ariaLabel": "切换语言" "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": { "chat": {
"fallbackTitle": "对话 {{id}}", "fallbackTitle": "对话 {{id}}",
"loading": "加载中…", "loading": "加载中…",

View File

@ -12,7 +12,8 @@
"system": { "system": {
"section": "系統", "section": "系統",
"restartHint": "重新啟動 nanobot 以套用執行階段變更。", "restartHint": "重新啟動 nanobot 以套用執行階段變更。",
"restart": "重新啟動 nanobot" "restart": "重新啟動 nanobot",
"restarting": "正在重新啟動..."
}, },
"restart": { "restart": {
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。" "completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
@ -31,11 +32,81 @@
"newChat": "新增對話", "newChat": "新增對話",
"recent": "最近對話", "recent": "最近對話",
"refreshSessions": "重新整理會話", "refreshSessions": "重新整理會話",
"settings": "設定",
"language": { "language": {
"label": "語言", "label": "語言",
"ariaLabel": "切換語言" "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": { "chat": {
"fallbackTitle": "對話 {{id}}", "fallbackTitle": "對話 {{id}}",
"loading": "載入中…", "loading": "載入中…",

View File

@ -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 { export class ApiError extends Error {
status: number; status: number;
@ -147,3 +153,18 @@ export async function updateSettings(
if (update.provider !== undefined) query.set("provider", update.provider); if (update.provider !== undefined) query.set("provider", update.provider);
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token); return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
} }
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,
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.apiBase !== undefined) query.set("api_base", update.apiBase);
return request<SettingsPayload>(
`${base}/api/settings/provider/update?${query}`,
token,
);
}

View File

@ -77,6 +77,10 @@ export interface SettingsPayload {
providers: Array<{ providers: Array<{
name: string; name: string;
label: string; label: string;
configured: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
}>; }>;
runtime: { runtime: {
config_path: string; config_path: string;
@ -89,6 +93,12 @@ export interface SettingsUpdate {
provider?: string; provider?: string;
} }
export interface ProviderSettingsUpdate {
provider: string;
apiKey?: string;
apiBase?: string;
}
export interface SlashCommand { export interface SlashCommand {
command: string; command: string;
title: string; title: string;

View File

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

View File

@ -155,7 +155,7 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none"); expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000); }, 15_000);
it("opens the Cursor-style settings view from the header", async () => { it("opens the settings view from the sidebar footer", async () => {
mockSessions = [ mockSessions = [
{ {
key: "websocket:chat-a", key: "websocket:chat-a",
@ -181,8 +181,13 @@ describe("App layout", () => {
has_api_key: true, has_api_key: true,
}, },
providers: [ providers: [
{ name: "auto", label: "Auto" }, { name: "openai", label: "OpenAI", configured: true },
{ name: "openai", label: "OpenAI" }, {
name: "openrouter",
label: "OpenRouter",
configured: false,
default_api_base: "https://openrouter.ai/api/v1",
},
], ],
runtime: { runtime: {
config_path: "/tmp/config.json", config_path: "/tmp/config.json",
@ -198,11 +203,24 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); 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(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.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" }));
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
}); });
it("filters sidebar sessions through the lightweight search row", async () => { 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.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.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: "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(); expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
}); });

View File

@ -8,6 +8,7 @@ import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"]; const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
const SETTINGS_NAV_KEYS = ["general", "byok"];
describe("webui i18n", () => { describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => { 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();
}
});
}); });