mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
feat(webui): redesign settings and BYOK configuration
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
451d740849
commit
2cc32ca07c
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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…",
|
||||
|
||||
@ -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…",
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
"system": {
|
||||
"section": "Système",
|
||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
||||
"restart": "Redémarrer nanobot"
|
||||
"restart": "Redémarrer nanobot",
|
||||
"restarting": "Redémarrage..."
|
||||
},
|
||||
"restart": {
|
||||
"completed": "Redémarrage terminé en {{seconds}} s."
|
||||
@ -31,11 +32,81 @@
|
||||
"newChat": "Nouvelle discussion",
|
||||
"recent": "Récentes",
|
||||
"refreshSessions": "Actualiser les sessions",
|
||||
"settings": "Paramètres",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"ariaLabel": "Changer de langue"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour à la discussion",
|
||||
"sidebar": {
|
||||
"title": "Paramètres",
|
||||
"ariaLabel": "Sections des paramètres"
|
||||
},
|
||||
"nav": {
|
||||
"general": "Général",
|
||||
"byok": "BYOK"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interface",
|
||||
"ai": "IA",
|
||||
"system": "Système"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Thème",
|
||||
"language": "Langue",
|
||||
"provider": "Fournisseur",
|
||||
"model": "Modèle",
|
||||
"restart": "Redémarrer nanobot",
|
||||
"configPath": "Chemin de configuration"
|
||||
},
|
||||
"help": {
|
||||
"theme": "Basculer entre les apparences claire et sombre.",
|
||||
"language": "Choisissez la langue utilisée par le WebUI.",
|
||||
"provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.",
|
||||
"model": "Définissez le nom du modèle par défaut utilisé par nanobot.",
|
||||
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle."
|
||||
},
|
||||
"values": {
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"notAvailable": "Indisponible"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement des paramètres...",
|
||||
"loadError": "Impossible de charger les paramètres",
|
||||
"unsaved": "Modifications non enregistrées.",
|
||||
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement",
|
||||
"edit": "Modifier",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"byok": {
|
||||
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
|
||||
"configured": "Configuré",
|
||||
"notConfigured": "Non configuré",
|
||||
"configuredSection": "Configurés",
|
||||
"notConfiguredSection": "Non configurés",
|
||||
"showMore": "Afficher {{count}} de plus",
|
||||
"showLess": "Afficher moins",
|
||||
"apiKey": "API key",
|
||||
"apiBase": "API base",
|
||||
"apiKeyPlaceholder": "Saisir l'API key",
|
||||
"apiKeyConfiguredPlaceholder": "Laisser vide pour conserver la key actuelle",
|
||||
"configuredKeyHint": "Key configurée",
|
||||
"apiBasePlaceholder": "Utiliser la valeur par défaut du fournisseur",
|
||||
"apiKeyRequired": "Une API key est requise pour configurer ce fournisseur.",
|
||||
"showApiKey": "Afficher l'API key",
|
||||
"hideApiKey": "Masquer l'API key",
|
||||
"noConfiguredProviders": "Aucun fournisseur configuré",
|
||||
"configureFirst": "Configurez d'abord un fournisseur dans BYOK.",
|
||||
"openByok": "Ouvrir BYOK"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fallbackTitle": "Discussion {{id}}",
|
||||
"loading": "Chargement…",
|
||||
|
||||
@ -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…",
|
||||
|
||||
@ -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": "読み込み中…",
|
||||
|
||||
@ -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": "불러오는 중…",
|
||||
|
||||
@ -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…",
|
||||
|
||||
@ -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": "加载中…",
|
||||
|
||||
@ -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": "載入中…",
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user