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

View File

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

View File

@ -253,6 +253,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
const lastSessionsLen = useRef(0);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(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 (
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<div
{showMainSidebar ? (
<aside
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: SIDEBAR_WIDTH }}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
<div
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
) : null}
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
<SheetContent
side="left"
showCloseButton={false}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
) : null}
<main className="flex h-full min-w-0 flex-1 flex-col">
{view === "settings" ? (
@ -456,6 +471,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
/>
) : (
<ThreadShell
@ -467,7 +483,6 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
)}

View File

@ -24,9 +24,9 @@ export default function MarkdownTextRenderer({
return (
<div
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-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-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",

View File

@ -73,7 +73,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<p
className={cn(
"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}
@ -87,7 +87,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const media = message.media ?? [];
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
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 ? (
<TypingDots />
) : (

View File

@ -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) {
/>
</div>
<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 />
</div>
</nav>

View File

@ -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<SettingsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [providerSaving, setProviderSaving] = 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({
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 (
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
<main className="mx-auto w-full max-w-[1000px] px-6 py-6">
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5" />
Back to chat
</button>
<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}
/>
<h1 className="mb-6 text-base font-semibold tracking-tight">General</h1>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading settings...
<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>
) : error ? (
<SettingsGroup>
<SettingsRow title="Could not load settings">
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow>
</SettingsGroup>
) : settings ? (
<SettingsSection
form={form}
setForm={setForm}
settings={settings}
dirty={dirty}
saving={saving}
onSave={save}
onLogout={onLogout}
onRestart={onRestart}
/>
) : null}
{loading ? (
<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" />
{t("settings.status.loading")}
</div>
) : error && !settings ? (
<SettingsGroup>
<SettingsRow title={t("settings.status.loadError")}>
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow>
</SettingsGroup>
) : settings ? (
<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}
setForm={setForm}
settings={settings}
dirty={dirty}
saving={saving}
onSave={save}
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}
</div>
</main>
</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,
setForm,
settings,
dirty,
saving,
onSave,
onLogout,
onRestart,
isRestarting,
onOpenByok,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
form: {
model: string;
provider: string;
};
setForm: React.Dispatch<React.SetStateAction<{
setForm: Dispatch<SetStateAction<{
model: string;
provider: string;
}>>;
@ -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 (
<div className="space-y-7">
<div className="space-y-8">
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">AI</h2>
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title="Provider">
<select
value={form.provider}
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))}
className={cn(
"h-8 w-[210px] rounded-md border border-input bg-background px-2 text-sm",
"outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
)}
<SettingsRow
title={t("settings.rows.theme")}
description={t("settings.help.theme")}
>
<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"
>
{settings.providers.map((provider) => (
<option key={provider.name} value={provider.name}>
{provider.label}
</option>
))}
</select>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "light" && "bg-background text-foreground shadow-sm",
)}
>
{t("settings.values.light")}
</span>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "dark" && "bg-background text-foreground shadow-sm",
)}
>
{t("settings.values.dark")}
</span>
</button>
</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
value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[280px]"
className="h-8 w-[280px] rounded-full text-[13px]"
/>
</SettingsRow>
@ -193,39 +479,46 @@ function SettingsSection({
onSave={onSave}
/>
) : null}
</SettingsGroup>
</section>
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">Interface</h2>
<SettingsGroup>
<SettingsRow title="Language">
<LanguageSwitcher />
</SettingsRow>
{configuredProviders.length === 0 ? (
<SettingsRow title={t("settings.byok.configureFirst")}>
<Button size="sm" variant="outline" onClick={onOpenByok} className="rounded-full">
{t("settings.byok.openByok")}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
{onRestart && (
<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>
<SettingsRow title={t("app.system.restartHint")}>
<Button size="sm" variant="outline" onClick={onRestart}>
{t("app.system.restart")}
<SettingsRow
title={t("settings.rows.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>
</SettingsRow>
</SettingsGroup>
</section>
)}
{onLogout && (
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
<SettingsGroup>
<SettingsRow title={t("app.account.logoutHint")}>
<Button size="sm" variant="outline" onClick={onLogout}>
{t("app.account.logout")}
</Button>
<SettingsRow
title={t("settings.rows.configPath")}
description={t("settings.help.configPath")}
>
<span className="max-w-[260px] truncate text-right text-[13px] text-muted-foreground">
{settings.runtime.config_path || t("settings.values.notAvailable")}
</span>
</SettingsRow>
</SettingsGroup>
</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 (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80">
<div className="divide-y divide-border/50">{children}</div>
<DropdownMenu>
<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>
);
}
function SettingsRow({
title,
description,
children,
}: {
title: string;
children?: React.ReactNode;
description?: string;
children?: ReactNode;
}) {
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="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>
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
</div>
@ -270,13 +915,14 @@ function SettingsFooter({
saved: boolean;
onSave: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-[52px] items-center justify-between gap-4 px-3 py-2.5">
<div className="text-sm text-muted-foreground">
{saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."}
<div className="flex min-h-[58px] items-center justify-between gap-4 px-4 py-3 sm:px-5">
<div className="text-[13px] text-muted-foreground">
{saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")}
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving}>
{saving ? "Saving" : "Save"}
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving} className="rounded-full">
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</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">
<MessageSquareText className="h-3.5 w-3.5" aria-hidden />
</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}
</p>
</div>
@ -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",
)}
/>

View File

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

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 { 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({
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-0.5">
<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>
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
</div>
);
}
@ -87,32 +62,35 @@ export function ThreadHeader({
</div>
</div>
<div className="flex items-center gap-0.5">
<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>
<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
variant="ghost"
size="icon"
aria-label={label}
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>
);
}

View File

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

View File

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

View File

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

View File

@ -12,7 +12,8 @@
"system": {
"section": "Système",
"restartHint": "Redémarrez nanobot pour appliquer les changements dexé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…",

View File

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

View File

@ -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": "読み込み中…",

View File

@ -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": "불러오는 중…",

View File

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

View File

@ -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": "加载中…",

View File

@ -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": "載入中…",

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 {
status: number;
@ -147,3 +153,18 @@ export async function updateSettings(
if (update.provider !== undefined) query.set("provider", update.provider);
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<{
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;

View File

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

View File

@ -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(<App />);
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();
});

View File

@ -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();
}
});
});