mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +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."""
|
"""Parse normalized path and query parameters in one pass."""
|
||||||
parsed = urlparse("ws://x" + path_with_query)
|
parsed = urlparse("ws://x" + path_with_query)
|
||||||
path = _strip_trailing_slash(parsed.path or "/")
|
path = _strip_trailing_slash(parsed.path or "/")
|
||||||
return path, parse_qs(parsed.query)
|
return path, parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_http_path(path_with_query: str) -> str:
|
def _normalize_http_path(path_with_query: str) -> str:
|
||||||
@ -189,6 +189,14 @@ def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
|||||||
return values[0] if values else None
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_secret_hint(secret: str | None) -> str | None:
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
if len(secret) <= 8:
|
||||||
|
return "••••"
|
||||||
|
return f"{secret[:4]}••••{secret[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
def _parse_inbound_payload(raw: str) -> str | None:
|
def _parse_inbound_payload(raw: str) -> str | None:
|
||||||
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
||||||
text = raw.strip()
|
text = raw.strip()
|
||||||
@ -560,6 +568,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == "/api/settings/update":
|
if got == "/api/settings/update":
|
||||||
return self._handle_settings_update(request)
|
return self._handle_settings_update(request)
|
||||||
|
|
||||||
|
if got == "/api/settings/provider/update":
|
||||||
|
return self._handle_settings_provider_update(request)
|
||||||
|
|
||||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_session_messages(request, m.group(1))
|
return self._handle_session_messages(request, m.group(1))
|
||||||
@ -688,6 +699,21 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if defaults.provider != "auto":
|
if defaults.provider != "auto":
|
||||||
spec = find_by_name(defaults.provider)
|
spec = find_by_name(defaults.provider)
|
||||||
selected_provider = spec.name if spec else provider_name
|
selected_provider = spec.name if spec else provider_name
|
||||||
|
providers = []
|
||||||
|
for spec in PROVIDERS:
|
||||||
|
provider_config = getattr(config.providers, spec.name, None)
|
||||||
|
if provider_config is None or spec.is_oauth or spec.is_local:
|
||||||
|
continue
|
||||||
|
providers.append(
|
||||||
|
{
|
||||||
|
"name": spec.name,
|
||||||
|
"label": spec.label,
|
||||||
|
"configured": bool(provider_config.api_key),
|
||||||
|
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||||
|
"api_base": provider_config.api_base,
|
||||||
|
"default_api_base": spec.default_api_base or None,
|
||||||
|
}
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"agent": {
|
"agent": {
|
||||||
"model": defaults.model,
|
"model": defaults.model,
|
||||||
@ -695,12 +721,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"resolved_provider": provider_name,
|
"resolved_provider": provider_name,
|
||||||
"has_api_key": bool(provider and provider.api_key),
|
"has_api_key": bool(provider and provider.api_key),
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": providers,
|
||||||
{"name": "auto", "label": "Auto"}
|
|
||||||
] + [
|
|
||||||
{"name": spec.name, "label": spec.label}
|
|
||||||
for spec in PROVIDERS
|
|
||||||
],
|
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"config_path": str(get_config_path().expanduser()),
|
"config_path": str(get_config_path().expanduser()),
|
||||||
},
|
},
|
||||||
@ -739,16 +760,66 @@ class WebSocketChannel(BaseChannel):
|
|||||||
|
|
||||||
provider = _query_first(query, "provider")
|
provider = _query_first(query, "provider")
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
provider = provider.strip() or "auto"
|
provider = provider.strip()
|
||||||
if provider != "auto" and find_by_name(provider) is None:
|
if not provider:
|
||||||
|
return _http_error(400, "provider is required")
|
||||||
|
if find_by_name(provider) is None:
|
||||||
return _http_error(400, "unknown provider")
|
return _http_error(400, "unknown provider")
|
||||||
|
provider_config = getattr(config.providers, provider, None)
|
||||||
|
if provider_config is None or not provider_config.api_key:
|
||||||
|
return _http_error(400, "provider is not configured")
|
||||||
if defaults.provider != provider:
|
if defaults.provider != provider:
|
||||||
defaults.provider = provider
|
defaults.provider = provider
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return _http_json_response(self._settings_payload(requires_restart=changed))
|
# LLM provider/model changes are hot-reloaded by AgentLoop before each
|
||||||
|
# new turn via the provider snapshot loader, so a restart is unnecessary.
|
||||||
|
return _http_json_response(self._settings_payload(requires_restart=False))
|
||||||
|
|
||||||
|
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._check_api_token(request):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
|
query = _parse_query(request.path)
|
||||||
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
|
if not provider_name:
|
||||||
|
return _http_error(400, "provider is required")
|
||||||
|
spec = find_by_name(provider_name)
|
||||||
|
if spec is None or spec.is_oauth or spec.is_local:
|
||||||
|
return _http_error(400, "unknown provider")
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
provider_config = getattr(config.providers, spec.name, None)
|
||||||
|
if provider_config is None:
|
||||||
|
return _http_error(400, "unknown provider")
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if "api_key" in query or "apiKey" in query:
|
||||||
|
api_key = _query_first(query, "api_key")
|
||||||
|
if api_key is None:
|
||||||
|
api_key = _query_first(query, "apiKey")
|
||||||
|
api_key = (api_key or "").strip() or None
|
||||||
|
if provider_config.api_key != api_key:
|
||||||
|
provider_config.api_key = api_key
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if "api_base" in query or "apiBase" in query:
|
||||||
|
api_base = _query_first(query, "api_base")
|
||||||
|
if api_base is None:
|
||||||
|
api_base = _query_first(query, "apiBase")
|
||||||
|
api_base = (api_base or "").strip() or None
|
||||||
|
if provider_config.api_base != api_base:
|
||||||
|
provider_config.api_base = api_base
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
save_config(config)
|
||||||
|
# API key/base changes are picked up by the next provider snapshot refresh.
|
||||||
|
return _http_json_response(self._settings_payload(requires_restart=False))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_webui_session_key(key: str) -> bool:
|
def _is_webui_session_key(key: str) -> bool:
|
||||||
|
|||||||
@ -542,10 +542,26 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
body = settings.json()
|
body = settings.json()
|
||||||
assert body["agent"]["model"] == "openai/gpt-4o"
|
assert body["agent"]["model"] == "openai/gpt-4o"
|
||||||
assert body["agent"]["provider"] == "openai"
|
assert body["agent"]["provider"] == "openai"
|
||||||
assert {"name": "auto", "label": "Auto"} in body["providers"]
|
providers = {provider["name"]: provider for provider in body["providers"]}
|
||||||
|
assert providers["openai"]["configured"] is True
|
||||||
|
assert providers["openai"]["api_key_hint"] == "secr••••-key"
|
||||||
|
assert providers["openrouter"]["configured"] is False
|
||||||
assert body["agent"]["has_api_key"] is True
|
assert body["agent"]["has_api_key"] is True
|
||||||
assert "secret-key" not in settings.text
|
assert "secret-key" not in settings.text
|
||||||
|
|
||||||
|
provider_updated = await _http_get(
|
||||||
|
"http://127.0.0.1:"
|
||||||
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
|
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
|
)
|
||||||
|
assert provider_updated.status_code == 200
|
||||||
|
provider_body = provider_updated.json()
|
||||||
|
assert provider_body["requires_restart"] is False
|
||||||
|
provider_rows = {provider["name"]: provider for provider in provider_body["providers"]}
|
||||||
|
assert provider_rows["openrouter"]["configured"] is True
|
||||||
|
assert "sk-or-test" not in provider_updated.text
|
||||||
|
|
||||||
updated = await _http_get(
|
updated = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
f"{port}/api/settings/update?model=openrouter/test"
|
f"{port}/api/settings/update?model=openrouter/test"
|
||||||
@ -553,11 +569,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
assert updated.json()["requires_restart"] is True
|
assert updated.json()["requires_restart"] is False
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "openrouter/test"
|
assert saved.agents.defaults.model == "openrouter/test"
|
||||||
assert saved.agents.defaults.provider == "openrouter"
|
assert saved.agents.defaults.provider == "openrouter"
|
||||||
|
assert saved.providers.openrouter.api_key == "sk-or-test"
|
||||||
|
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|||||||
@ -253,6 +253,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
const lastSessionsLen = useRef(0);
|
const lastSessionsLen = useRef(0);
|
||||||
const restartSawDisconnectRef = useRef(false);
|
const restartSawDisconnectRef = useRef(false);
|
||||||
const [restartToast, setRestartToast] = useState<string | null>(null);
|
const [restartToast, setRestartToast] = useState<string | null>(null);
|
||||||
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -334,6 +335,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
restartSawDisconnectRef.current = false;
|
restartSawDisconnectRef.current = false;
|
||||||
|
setIsRestarting(true);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
||||||
} catch {
|
} catch {
|
||||||
@ -362,6 +364,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
|
setIsRestarting(false);
|
||||||
setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) }));
|
setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) }));
|
||||||
window.setTimeout(() => setRestartToast(null), 3_500);
|
window.setTimeout(() => setRestartToast(null), 3_500);
|
||||||
});
|
});
|
||||||
@ -396,10 +399,16 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (view === "settings") {
|
||||||
|
document.title = t("app.documentTitle.chat", {
|
||||||
|
title: t("settings.sidebar.title"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
document.title = activeSession
|
document.title = activeSession
|
||||||
? t("app.documentTitle.chat", { title: headerTitle })
|
? t("app.documentTitle.chat", { title: headerTitle })
|
||||||
: t("app.documentTitle.base");
|
: t("app.documentTitle.base");
|
||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions,
|
sessions,
|
||||||
@ -409,43 +418,49 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onRequestDelete: (key: string, label: string) =>
|
onRequestDelete: (key: string, label: string) =>
|
||||||
setPendingDelete({ key, label }),
|
setPendingDelete({ key, label }),
|
||||||
|
onOpenSettings,
|
||||||
};
|
};
|
||||||
|
const showMainSidebar = view !== "settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||||
<aside
|
{showMainSidebar ? (
|
||||||
className={cn(
|
<aside
|
||||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
|
||||||
"transition-[width] duration-300 ease-out",
|
|
||||||
)}
|
|
||||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||||
"transition-transform duration-300 ease-out",
|
"transition-[width] duration-300 ease-out",
|
||||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
)}
|
||||||
style={{ width: SIDEBAR_WIDTH }}
|
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||||
>
|
>
|
||||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
</aside>
|
"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
|
{showMainSidebar ? (
|
||||||
open={mobileSidebarOpen}
|
<Sheet
|
||||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
open={mobileSidebarOpen}
|
||||||
>
|
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||||
<SheetContent
|
|
||||||
side="left"
|
|
||||||
showCloseButton={false}
|
|
||||||
className="p-0 lg:hidden"
|
|
||||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
|
||||||
>
|
>
|
||||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
<SheetContent
|
||||||
</SheetContent>
|
side="left"
|
||||||
</Sheet>
|
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">
|
<main className="flex h-full min-w-0 flex-1 flex-col">
|
||||||
{view === "settings" ? (
|
{view === "settings" ? (
|
||||||
@ -456,6 +471,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
onModelNameChange={onModelNameChange}
|
onModelNameChange={onModelNameChange}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onRestart={onRestart}
|
onRestart={onRestart}
|
||||||
|
isRestarting={isRestarting}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
@ -467,7 +483,6 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -24,9 +24,9 @@ export default function MarkdownTextRenderer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"markdown-content prose prose-lg max-w-none dark:prose-invert",
|
"markdown-content prose max-w-none dark:prose-invert",
|
||||||
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
||||||
"prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm",
|
"prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-[13px]",
|
||||||
"prose-p:my-2",
|
"prose-p:my-2",
|
||||||
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
|
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
|
||||||
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
|
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2",
|
"ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2",
|
||||||
"text-left text-[18px]/[1.8] whitespace-pre-wrap break-words",
|
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
@ -87,7 +87,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
const media = message.media ?? [];
|
const media = message.media ?? [];
|
||||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||||
{empty && message.isStreaming ? (
|
{empty && message.isStreaming ? (
|
||||||
<TypingDots />
|
<TypingDots />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -20,6 +21,7 @@ interface SidebarProps {
|
|||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +115,16 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-sidebar-border/50" />
|
<Separator className="bg-sidebar-border/50" />
|
||||||
<div className="flex items-center px-2.5 py-2.5 text-xs">
|
<div className="space-y-1 px-2.5 py-2.5 text-xs">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={props.onOpenSettings}
|
||||||
|
className="h-8 w-full justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t("sidebar.settings")}
|
||||||
|
</Button>
|
||||||
<ConnectionBadge />
|
<ConnectionBadge />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,15 +1,51 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
import {
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronDown,
|
||||||
|
Check,
|
||||||
|
Cloud,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Pencil,
|
||||||
|
Gem,
|
||||||
|
Grid3X3,
|
||||||
|
Hexagon,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
KeyRound,
|
||||||
|
Layers,
|
||||||
|
Moon,
|
||||||
|
Orbit,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Triangle,
|
||||||
|
Waves,
|
||||||
|
Zap,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { fetchSettings, updateSettings } from "@/lib/api";
|
import { fetchSettings, updateProviderSettings, updateSettings } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import type { SettingsPayload } from "@/lib/types";
|
import type { SettingsPayload } from "@/lib/types";
|
||||||
|
|
||||||
|
type SettingsSectionKey = "general" | "byok";
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
@ -17,22 +53,33 @@ interface SettingsViewProps {
|
|||||||
onModelNameChange: (modelName: string | null) => void;
|
onModelNameChange: (modelName: string | null) => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
|
isRestarting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsView({
|
export function SettingsView({
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
onBackToChat,
|
onBackToChat,
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
onLogout,
|
onLogout,
|
||||||
onRestart,
|
onRestart,
|
||||||
|
isRestarting = false,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("general");
|
||||||
|
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||||
|
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
|
||||||
|
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||||
|
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
model: "",
|
model: "",
|
||||||
provider: "auto",
|
provider: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyPayload = useCallback((payload: SettingsPayload) => {
|
const applyPayload = useCallback((payload: SettingsPayload) => {
|
||||||
@ -64,6 +111,20 @@ export function SettingsView({
|
|||||||
};
|
};
|
||||||
}, [applyPayload, token]);
|
}, [applyPayload, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
setProviderForms((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const provider of settings.providers) {
|
||||||
|
next[provider.name] = {
|
||||||
|
apiKey: next[provider.name]?.apiKey ?? "",
|
||||||
|
apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
const dirty = useMemo(() => {
|
const dirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
return (
|
return (
|
||||||
@ -76,7 +137,10 @@ export function SettingsView({
|
|||||||
if (!dirty || saving) return;
|
if (!dirty || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateSettings(token, form);
|
const payload = await updateSettings(token, {
|
||||||
|
model: form.model,
|
||||||
|
...(form.provider ? { provider: form.provider } : {}),
|
||||||
|
});
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
onModelNameChange(payload.agent.model || null);
|
onModelNameChange(payload.agent.model || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -87,63 +151,242 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
<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%)]">
|
||||||
<main className="mx-auto w-full max-w-[1000px] px-6 py-6">
|
<SettingsSidebar
|
||||||
<button
|
activeSection={activeSection}
|
||||||
type="button"
|
onSelectSection={setActiveSection}
|
||||||
onClick={onBackToChat}
|
onBackToChat={onBackToChat}
|
||||||
className="mb-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
onLogout={onLogout}
|
||||||
>
|
/>
|
||||||
<ChevronLeft className="h-3.5 w-3.5" />
|
|
||||||
Back to chat
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h1 className="mb-6 text-base font-semibold tracking-tight">General</h1>
|
<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">
|
||||||
{loading ? (
|
<div className="mb-8">
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
{t("settings.sidebar.title")}
|
||||||
Loading settings...
|
</p>
|
||||||
|
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.035em] text-foreground sm:text-[34px]">
|
||||||
|
{t(`settings.nav.${activeSection}`)}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
|
||||||
<SettingsGroup>
|
{loading ? (
|
||||||
<SettingsRow title="Could not load settings">
|
<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)]">
|
||||||
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
</SettingsRow>
|
{t("settings.status.loading")}
|
||||||
</SettingsGroup>
|
</div>
|
||||||
) : settings ? (
|
) : error && !settings ? (
|
||||||
<SettingsSection
|
<SettingsGroup>
|
||||||
form={form}
|
<SettingsRow title={t("settings.status.loadError")}>
|
||||||
setForm={setForm}
|
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
||||||
settings={settings}
|
</SettingsRow>
|
||||||
dirty={dirty}
|
</SettingsGroup>
|
||||||
saving={saving}
|
) : settings ? (
|
||||||
onSave={save}
|
<div className="space-y-5">
|
||||||
onLogout={onLogout}
|
{error ? (
|
||||||
onRestart={onRestart}
|
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||||
/>
|
{error}
|
||||||
) : null}
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsSection({
|
const SETTINGS_NAV_ITEMS = [
|
||||||
|
{ key: "general", icon: Settings },
|
||||||
|
{ key: "byok", icon: KeyRound },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function SettingsSidebar({
|
||||||
|
activeSection,
|
||||||
|
onSelectSection,
|
||||||
|
onBackToChat,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
activeSection: SettingsSectionKey;
|
||||||
|
onSelectSection: (section: SettingsSectionKey) => void;
|
||||||
|
onBackToChat: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<aside className="flex w-[17rem] shrink-0 flex-col border-r border-border/55 bg-card/62 px-3 py-4 shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackToChat}
|
||||||
|
className="mb-3 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t("settings.backToChat")}
|
||||||
|
</button>
|
||||||
|
<div className="mb-5 px-2">
|
||||||
|
<h2 className="text-[21px] font-semibold tracking-[-0.035em] text-foreground">
|
||||||
|
{t("settings.sidebar.title")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label={t("settings.sidebar.ariaLabel")} className="space-y-1">
|
||||||
|
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon }) => {
|
||||||
|
const active = key === activeSection;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
onClick={() => onSelectSection(key)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-muted/90 text-foreground shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
|
||||||
|
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||||
|
<span className="truncate">{t(`settings.nav.${key}`)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-4">
|
||||||
|
{onLogout ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" aria-hidden />
|
||||||
|
{t("app.account.logout")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralSettings({
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
settings,
|
settings,
|
||||||
dirty,
|
dirty,
|
||||||
saving,
|
saving,
|
||||||
onSave,
|
onSave,
|
||||||
onLogout,
|
|
||||||
onRestart,
|
onRestart,
|
||||||
|
isRestarting,
|
||||||
|
onOpenByok,
|
||||||
}: {
|
}: {
|
||||||
|
theme: "light" | "dark";
|
||||||
|
onToggleTheme: () => void;
|
||||||
form: {
|
form: {
|
||||||
model: string;
|
model: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
};
|
};
|
||||||
setForm: React.Dispatch<React.SetStateAction<{
|
setForm: Dispatch<SetStateAction<{
|
||||||
model: string;
|
model: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
}>>;
|
}>>;
|
||||||
@ -151,37 +394,80 @@ function SettingsSection({
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onLogout?: () => void;
|
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
|
isRestarting?: boolean;
|
||||||
|
onOpenByok: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||||
|
const providerValue = configuredProviders.some((provider) => provider.name === form.provider)
|
||||||
|
? form.provider
|
||||||
|
: "";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-8">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">AI</h2>
|
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow title="Provider">
|
<SettingsRow
|
||||||
<select
|
title={t("settings.rows.theme")}
|
||||||
value={form.provider}
|
description={t("settings.help.theme")}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))}
|
>
|
||||||
className={cn(
|
<button
|
||||||
"h-8 w-[210px] rounded-md border border-input bg-background px-2 text-sm",
|
type="button"
|
||||||
"outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
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) => (
|
<span
|
||||||
<option key={provider.name} value={provider.name}>
|
className={cn(
|
||||||
{provider.label}
|
"rounded-full px-3 py-1 transition-colors",
|
||||||
</option>
|
theme === "light" && "bg-background text-foreground shadow-sm",
|
||||||
))}
|
)}
|
||||||
</select>
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
<SettingsRow title="Model">
|
<SettingsRow
|
||||||
|
title={t("settings.rows.language")}
|
||||||
|
description={t("settings.help.language")}
|
||||||
|
>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SettingsSectionTitle>{t("settings.sections.ai")}</SettingsSectionTitle>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsRow
|
||||||
|
title={t("settings.rows.provider")}
|
||||||
|
description={t("settings.help.provider")}
|
||||||
|
>
|
||||||
|
<ProviderPicker
|
||||||
|
providers={configuredProviders}
|
||||||
|
value={providerValue}
|
||||||
|
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||||
|
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={t("settings.rows.model")}
|
||||||
|
description={t("settings.help.model")}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.model}
|
value={form.model}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
|
||||||
className="h-8 w-[280px]"
|
className="h-8 w-[280px] rounded-full text-[13px]"
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
@ -193,39 +479,46 @@ function SettingsSection({
|
|||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SettingsGroup>
|
{configuredProviders.length === 0 ? (
|
||||||
</section>
|
<SettingsRow title={t("settings.byok.configureFirst")}>
|
||||||
|
<Button size="sm" variant="outline" onClick={onOpenByok} className="rounded-full">
|
||||||
<section>
|
{t("settings.byok.openByok")}
|
||||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">Interface</h2>
|
</Button>
|
||||||
<SettingsGroup>
|
</SettingsRow>
|
||||||
<SettingsRow title="Language">
|
) : null}
|
||||||
<LanguageSwitcher />
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{onRestart && (
|
{onRestart && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.system.section")}</h2>
|
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow title={t("app.system.restartHint")}>
|
<SettingsRow
|
||||||
<Button size="sm" variant="outline" onClick={onRestart}>
|
title={t("settings.rows.restart")}
|
||||||
{t("app.system.restart")}
|
description={t("app.system.restartHint")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRestart}
|
||||||
|
disabled={isRestarting}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
{isRestarting ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
<SettingsRow
|
||||||
</section>
|
title={t("settings.rows.configPath")}
|
||||||
)}
|
description={t("settings.help.configPath")}
|
||||||
|
>
|
||||||
{onLogout && (
|
<span className="max-w-[260px] truncate text-right text-[13px] text-muted-foreground">
|
||||||
<section>
|
{settings.runtime.config_path || t("settings.values.notAvailable")}
|
||||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
|
</span>
|
||||||
<SettingsGroup>
|
|
||||||
<SettingsRow title={t("app.account.logoutHint")}>
|
|
||||||
<Button size="sm" variant="outline" onClick={onLogout}>
|
|
||||||
{t("app.account.logout")}
|
|
||||||
</Button>
|
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</section>
|
</section>
|
||||||
@ -234,25 +527,377 @@ function SettingsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsGroup({ children }: { children: React.ReactNode }) {
|
function ProviderPicker({
|
||||||
|
providers,
|
||||||
|
value,
|
||||||
|
emptyLabel,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
providers: SettingsPayload["providers"];
|
||||||
|
value: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
onChange: (provider: string) => void;
|
||||||
|
}) {
|
||||||
|
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
|
||||||
|
const disabled = providers.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80">
|
<DropdownMenu>
|
||||||
<div className="divide-y divide-border/50">{children}</div>
|
<DropdownMenuTrigger asChild disabled={disabled}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
|
||||||
|
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
disabled && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
|
||||||
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="max-h-[18rem] w-[240px] overflow-y-auto rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
|
||||||
|
>
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const selected = provider.name === value;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={provider.name}
|
||||||
|
onSelect={() => onChange(provider.name)}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-3 py-2 text-[13px]",
|
||||||
|
"focus:bg-muted focus:text-foreground",
|
||||||
|
selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{provider.label}</span>
|
||||||
|
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ByokSettings({
|
||||||
|
settings,
|
||||||
|
expandedProvider,
|
||||||
|
providerForms,
|
||||||
|
visibleProviderKeys,
|
||||||
|
editingProviderKeys,
|
||||||
|
providerSaving,
|
||||||
|
onToggleProvider,
|
||||||
|
onToggleProviderKey,
|
||||||
|
onToggleProviderKeyEditing,
|
||||||
|
onChangeProviderForm,
|
||||||
|
onSaveProvider,
|
||||||
|
}: {
|
||||||
|
settings: SettingsPayload;
|
||||||
|
expandedProvider: string | null;
|
||||||
|
providerForms: Record<string, { apiKey: string; apiBase: string }>;
|
||||||
|
visibleProviderKeys: Record<string, boolean>;
|
||||||
|
editingProviderKeys: Record<string, boolean>;
|
||||||
|
providerSaving: string | null;
|
||||||
|
onToggleProvider: (provider: string) => void;
|
||||||
|
onToggleProviderKey: (provider: string) => void;
|
||||||
|
onToggleProviderKeyEditing: (provider: string) => void;
|
||||||
|
onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void;
|
||||||
|
onSaveProvider: (provider: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
|
||||||
|
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||||
|
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
|
||||||
|
const initialUnconfiguredCount = 6;
|
||||||
|
const visibleUnconfiguredProviders = showAllUnconfigured
|
||||||
|
? unconfiguredProviders
|
||||||
|
: unconfiguredProviders.slice(0, initialUnconfiguredCount);
|
||||||
|
const hiddenUnconfiguredCount = Math.max(
|
||||||
|
0,
|
||||||
|
unconfiguredProviders.length - visibleUnconfiguredProviders.length,
|
||||||
|
);
|
||||||
|
const renderProviderRow = (provider: SettingsPayload["providers"][number]) => {
|
||||||
|
const expanded = expandedProvider === provider.name;
|
||||||
|
const form = providerForms[provider.name] ?? {
|
||||||
|
apiKey: "",
|
||||||
|
apiBase: provider.api_base ?? provider.default_api_base ?? "",
|
||||||
|
};
|
||||||
|
const saving = providerSaving === provider.name;
|
||||||
|
const keyVisible = !!visibleProviderKeys[provider.name];
|
||||||
|
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.name}
|
||||||
|
className="divide-y divide-border/45"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleProvider(provider.name)}
|
||||||
|
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<ProviderIcon provider={provider.name} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||||
|
{provider.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-1 text-[12px] font-medium",
|
||||||
|
provider.configured
|
||||||
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{provider.configured
|
||||||
|
? t("settings.byok.configured")
|
||||||
|
: t("settings.byok.notConfigured")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="space-y-3 bg-muted/18 px-4 py-4 sm:px-5">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-muted-foreground">
|
||||||
|
{t("settings.byok.apiKey")}
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
{editingKey ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type={keyVisible ? "text" : "password"}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChangeProviderForm(provider.name, { apiKey: event.target.value })
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
provider.configured
|
||||||
|
? t("settings.byok.apiKeyConfiguredPlaceholder")
|
||||||
|
: t("settings.byok.apiKeyPlaceholder")
|
||||||
|
}
|
||||||
|
className="h-9 rounded-full pr-11 text-[13px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleProviderKey(provider.name)}
|
||||||
|
aria-label={
|
||||||
|
keyVisible
|
||||||
|
? t("settings.byok.hideApiKey")
|
||||||
|
: t("settings.byok.showApiKey")
|
||||||
|
}
|
||||||
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{keyVisible ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
|
||||||
|
{provider.api_key_hint ?? t("settings.byok.configuredKeyHint")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleProviderKeyEditing(provider.name)}
|
||||||
|
aria-label={t("settings.actions.edit")}
|
||||||
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-muted-foreground">
|
||||||
|
{t("settings.byok.apiBase")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={form.apiBase}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChangeProviderForm(provider.name, { apiBase: event.target.value })
|
||||||
|
}
|
||||||
|
placeholder={provider.default_api_base ?? t("settings.byok.apiBasePlaceholder")}
|
||||||
|
className="h-9 rounded-full text-[13px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onSaveProvider(provider.name)}
|
||||||
|
disabled={saving || (!provider.configured && !form.apiKey.trim())}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="max-w-[42rem] text-[13px] leading-6 text-muted-foreground">
|
||||||
|
{t("settings.byok.description")}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<ByokSectionHeader
|
||||||
|
title={t("settings.byok.configuredSection")}
|
||||||
|
count={configuredProviders.length}
|
||||||
|
/>
|
||||||
|
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
|
||||||
|
{configuredProviders.length > 0 ? (
|
||||||
|
<div className="divide-y divide-border/45">
|
||||||
|
{configuredProviders.map(renderProviderRow)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ByokEmptyState>{t("settings.byok.noConfiguredProviders")}</ByokEmptyState>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<ByokSectionHeader
|
||||||
|
title={t("settings.byok.notConfiguredSection")}
|
||||||
|
count={unconfiguredProviders.length}
|
||||||
|
/>
|
||||||
|
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
|
||||||
|
<div className="divide-y divide-border/45">
|
||||||
|
{visibleUnconfiguredProviders.map(renderProviderRow)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hiddenUnconfiguredCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAllUnconfigured(true)}
|
||||||
|
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("settings.byok.showMore", { count: hiddenUnconfiguredCount })}
|
||||||
|
</Button>
|
||||||
|
) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAllUnconfigured(false)}
|
||||||
|
className="h-9 rounded-full px-3 text-[13px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("settings.byok.showLess")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ByokSectionHeader({ title, count }: { title: string; count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[11.5px] font-medium text-muted-foreground">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ByokEmptyState({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[18px] border border-dashed border-border/65 bg-card/45 px-4 py-5 text-[13px] text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||||
|
custom: Hexagon,
|
||||||
|
openrouter: Sparkles,
|
||||||
|
aihubmix: Triangle,
|
||||||
|
anthropic: Brain,
|
||||||
|
openai: Bot,
|
||||||
|
deepseek: Waves,
|
||||||
|
zhipu: Grid3X3,
|
||||||
|
dashscope: Cloud,
|
||||||
|
moonshot: Moon,
|
||||||
|
minimax: Zap,
|
||||||
|
minimax_anthropic: Brain,
|
||||||
|
groq: Cpu,
|
||||||
|
huggingface: Layers,
|
||||||
|
gemini: Gem,
|
||||||
|
mistral: Orbit,
|
||||||
|
siliconflow: Layers,
|
||||||
|
volcengine: Cloud,
|
||||||
|
volcengine_coding_plan: Cloud,
|
||||||
|
byteplus: Cloud,
|
||||||
|
byteplus_coding_plan: Cloud,
|
||||||
|
qianfan: Database,
|
||||||
|
azure_openai: Cloud,
|
||||||
|
bedrock: Database,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProviderIcon({ provider }: { provider: string }) {
|
||||||
|
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||||
|
return (
|
||||||
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
|
||||||
|
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSectionTitle({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsGroup({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
|
||||||
|
<div className="divide-y divide-border/45">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsRow({
|
function SettingsRow({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
children?: React.ReactNode;
|
description?: string;
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[52px] flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium leading-5">{title}</div>
|
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
|
||||||
|
{description ? (
|
||||||
|
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
|
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -270,13 +915,14 @@ function SettingsFooter({
|
|||||||
saved: boolean;
|
saved: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[52px] items-center justify-between gap-4 px-3 py-2.5">
|
<div className="flex min-h-[58px] items-center justify-between gap-4 px-4 py-3 sm:px-5">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-[13px] text-muted-foreground">
|
||||||
{saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."}
|
{saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving}>
|
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving} className="rounded-full">
|
||||||
{saving ? "Saving" : "Save"}
|
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export function AskUserPrompt({
|
|||||||
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
|
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
|
||||||
<MessageSquareText className="h-3.5 w-3.5" aria-hidden />
|
<MessageSquareText className="h-3.5 w-3.5" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<p className="min-w-0 flex-1 text-sm font-medium leading-5 text-foreground">
|
<p className="min-w-0 flex-1 text-[13.5px] font-medium leading-5 text-foreground">
|
||||||
{question}
|
{question}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -94,7 +94,7 @@ export function AskUserPrompt({
|
|||||||
placeholder="Type your own answer..."
|
placeholder="Type your own answer..."
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-9 flex-1 resize-none rounded-[10px] border border-border/70 bg-background",
|
"min-h-9 flex-1 resize-none rounded-[10px] border border-border/70 bg-background",
|
||||||
"px-3 py-2 text-sm leading-5 outline-none placeholder:text-muted-foreground",
|
"px-3 py-2 text-[13.5px] leading-5 outline-none placeholder:text-muted-foreground",
|
||||||
"focus-visible:ring-1 focus-visible:ring-primary/40",
|
"focus-visible:ring-1 focus-visible:ring-primary/40",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -473,8 +473,8 @@ export function ThreadComposer({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full resize-none bg-transparent",
|
"w-full resize-none bg-transparent",
|
||||||
isHero
|
isHero
|
||||||
? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6"
|
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
||||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
|
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||||
"placeholder:text-muted-foreground/70",
|
"placeholder:text-muted-foreground/70",
|
||||||
"focus:outline-none focus-visible:outline-none",
|
"focus:outline-none focus-visible:outline-none",
|
||||||
"disabled:cursor-not-allowed",
|
"disabled:cursor-not-allowed",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Menu, Moon, Settings, Sun } from "lucide-react";
|
import { Menu, Moon, Sun } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -9,7 +9,6 @@ interface ThreadHeaderProps {
|
|||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
onOpenSettings: () => void;
|
|
||||||
hideSidebarToggleOnDesktop?: boolean;
|
hideSidebarToggleOnDesktop?: boolean;
|
||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
}
|
}
|
||||||
@ -19,7 +18,6 @@ export function ThreadHeader({
|
|||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
onOpenSettings,
|
|
||||||
hideSidebarToggleOnDesktop = false,
|
hideSidebarToggleOnDesktop = false,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
}: ThreadHeaderProps) {
|
}: ThreadHeaderProps) {
|
||||||
@ -39,30 +37,7 @@ export function ThreadHeader({
|
|||||||
>
|
>
|
||||||
<Menu className="h-3.5 w-3.5" />
|
<Menu className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-0.5">
|
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t("thread.header.toggleTheme")}
|
|
||||||
onClick={onToggleTheme}
|
|
||||||
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Sun className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t("thread.header.settings")}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -87,32 +62,35 @@ export function ThreadHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5">
|
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t("thread.header.toggleTheme")}
|
|
||||||
onClick={onToggleTheme}
|
|
||||||
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Sun className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t("thread.header.settings")}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
||||||
</div>
|
</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;
|
onTurnEnd?: () => void;
|
||||||
theme?: "light" | "dark";
|
theme?: "light" | "dark";
|
||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
onOpenSettings?: () => void;
|
|
||||||
hideSidebarToggleOnDesktop?: boolean;
|
hideSidebarToggleOnDesktop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +77,6 @@ export function ThreadShell({
|
|||||||
onTurnEnd,
|
onTurnEnd,
|
||||||
theme = "light",
|
theme = "light",
|
||||||
onToggleTheme = () => {},
|
onToggleTheme = () => {},
|
||||||
onOpenSettings = () => {},
|
|
||||||
hideSidebarToggleOnDesktop = false,
|
hideSidebarToggleOnDesktop = false,
|
||||||
}: ThreadShellProps) {
|
}: ThreadShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -312,7 +310,6 @@ export function ThreadShell({
|
|||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -24,7 +24,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "System",
|
"section": "System",
|
||||||
"restartHint": "Restart nanobot to apply runtime changes.",
|
"restartHint": "Restart nanobot to apply runtime changes.",
|
||||||
"restart": "Restart nanobot"
|
"restart": "Restart nanobot",
|
||||||
|
"restarting": "Restarting..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Restart completed in {{seconds}}s."
|
"completed": "Restart completed in {{seconds}}s."
|
||||||
@ -56,6 +57,75 @@
|
|||||||
"ariaLabel": "Change language"
|
"ariaLabel": "Change language"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "Back to chat",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Settings",
|
||||||
|
"ariaLabel": "Settings sections"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "General",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "Interface",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "Theme",
|
||||||
|
"language": "Language",
|
||||||
|
"provider": "Provider",
|
||||||
|
"model": "Model",
|
||||||
|
"restart": "Restart nanobot",
|
||||||
|
"configPath": "Config path"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "Switch between light and dark appearance.",
|
||||||
|
"language": "Choose the language used by the WebUI.",
|
||||||
|
"provider": "Select the provider that should serve new model requests.",
|
||||||
|
"model": "Set the default model name used by nanobot.",
|
||||||
|
"configPath": "The gateway configuration file currently in use."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"notAvailable": "Not available"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "Loading settings...",
|
||||||
|
"loadError": "Could not load settings",
|
||||||
|
"unsaved": "Unsaved changes.",
|
||||||
|
"savedRestart": "Saved. Restart nanobot to apply."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving",
|
||||||
|
"edit": "Edit",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
|
||||||
|
"configured": "Configured",
|
||||||
|
"notConfigured": "Not configured",
|
||||||
|
"configuredSection": "Configured",
|
||||||
|
"notConfiguredSection": "Not configured",
|
||||||
|
"showMore": "Show {{count}} more",
|
||||||
|
"showLess": "Show fewer",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "Enter API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "Leave blank to keep the current key",
|
||||||
|
"configuredKeyHint": "Configured key",
|
||||||
|
"apiBasePlaceholder": "Use provider default",
|
||||||
|
"apiKeyRequired": "API key is required to configure this provider.",
|
||||||
|
"showApiKey": "Show API key",
|
||||||
|
"hideApiKey": "Hide API key",
|
||||||
|
"noConfiguredProviders": "No configured providers",
|
||||||
|
"configureFirst": "Configure a provider in BYOK first.",
|
||||||
|
"openByok": "Open BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Chat {{id}}",
|
"fallbackTitle": "Chat {{id}}",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "Sistema",
|
"section": "Sistema",
|
||||||
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
|
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
|
||||||
"restart": "Reiniciar nanobot"
|
"restart": "Reiniciar nanobot",
|
||||||
|
"restarting": "Reiniciando..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Reinicio completado en {{seconds}} s."
|
"completed": "Reinicio completado en {{seconds}} s."
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "Nuevo chat",
|
"newChat": "Nuevo chat",
|
||||||
"recent": "Recientes",
|
"recent": "Recientes",
|
||||||
"refreshSessions": "Actualizar sesiones",
|
"refreshSessions": "Actualizar sesiones",
|
||||||
|
"settings": "Configuración",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
"ariaLabel": "Cambiar idioma"
|
"ariaLabel": "Cambiar idioma"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "Volver al chat",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Configuración",
|
||||||
|
"ariaLabel": "Secciones de configuración"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "General",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "Interfaz",
|
||||||
|
"ai": "IA",
|
||||||
|
"system": "Sistema"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "Tema",
|
||||||
|
"language": "Idioma",
|
||||||
|
"provider": "Proveedor",
|
||||||
|
"model": "Modelo",
|
||||||
|
"restart": "Reiniciar nanobot",
|
||||||
|
"configPath": "Ruta de configuración"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "Cambia entre apariencia clara y oscura.",
|
||||||
|
"language": "Elige el idioma usado por la WebUI.",
|
||||||
|
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
|
||||||
|
"model": "Define el nombre del modelo predeterminado que usa nanobot.",
|
||||||
|
"configPath": "El archivo de configuración que usa actualmente el gateway."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "Claro",
|
||||||
|
"dark": "Oscuro",
|
||||||
|
"notAvailable": "No disponible"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "Cargando configuración...",
|
||||||
|
"loadError": "No se pudo cargar la configuración",
|
||||||
|
"unsaved": "Hay cambios sin guardar.",
|
||||||
|
"savedRestart": "Guardado. Reinicia nanobot para aplicar."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Guardar",
|
||||||
|
"saving": "Guardando",
|
||||||
|
"edit": "Editar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
|
||||||
|
"configured": "Configurado",
|
||||||
|
"notConfigured": "Sin configurar",
|
||||||
|
"configuredSection": "Configurados",
|
||||||
|
"notConfiguredSection": "Sin configurar",
|
||||||
|
"showMore": "Mostrar {{count}} más",
|
||||||
|
"showLess": "Mostrar menos",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "Introduce la API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "Deja vacío para conservar la key actual",
|
||||||
|
"configuredKeyHint": "Key configurada",
|
||||||
|
"apiBasePlaceholder": "Usar el valor predeterminado del proveedor",
|
||||||
|
"apiKeyRequired": "Se requiere una API key para configurar este proveedor.",
|
||||||
|
"showApiKey": "Mostrar API key",
|
||||||
|
"hideApiKey": "Ocultar API key",
|
||||||
|
"noConfiguredProviders": "No hay proveedores configurados",
|
||||||
|
"configureFirst": "Configura primero un proveedor en BYOK.",
|
||||||
|
"openByok": "Abrir BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Chat {{id}}",
|
"fallbackTitle": "Chat {{id}}",
|
||||||
"loading": "Cargando…",
|
"loading": "Cargando…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "Système",
|
"section": "Système",
|
||||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
||||||
"restart": "Redémarrer nanobot"
|
"restart": "Redémarrer nanobot",
|
||||||
|
"restarting": "Redémarrage..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Redémarrage terminé en {{seconds}} s."
|
"completed": "Redémarrage terminé en {{seconds}} s."
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "Nouvelle discussion",
|
"newChat": "Nouvelle discussion",
|
||||||
"recent": "Récentes",
|
"recent": "Récentes",
|
||||||
"refreshSessions": "Actualiser les sessions",
|
"refreshSessions": "Actualiser les sessions",
|
||||||
|
"settings": "Paramètres",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Langue",
|
"label": "Langue",
|
||||||
"ariaLabel": "Changer de langue"
|
"ariaLabel": "Changer de langue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "Retour à la discussion",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"ariaLabel": "Sections des paramètres"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "Général",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "Interface",
|
||||||
|
"ai": "IA",
|
||||||
|
"system": "Système"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "Thème",
|
||||||
|
"language": "Langue",
|
||||||
|
"provider": "Fournisseur",
|
||||||
|
"model": "Modèle",
|
||||||
|
"restart": "Redémarrer nanobot",
|
||||||
|
"configPath": "Chemin de configuration"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "Basculer entre les apparences claire et sombre.",
|
||||||
|
"language": "Choisissez la langue utilisée par le WebUI.",
|
||||||
|
"provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.",
|
||||||
|
"model": "Définissez le nom du modèle par défaut utilisé par nanobot.",
|
||||||
|
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "Clair",
|
||||||
|
"dark": "Sombre",
|
||||||
|
"notAvailable": "Indisponible"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "Chargement des paramètres...",
|
||||||
|
"loadError": "Impossible de charger les paramètres",
|
||||||
|
"unsaved": "Modifications non enregistrées.",
|
||||||
|
"savedRestart": "Enregistré. Redémarrez nanobot pour appliquer."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saving": "Enregistrement",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
|
||||||
|
"configured": "Configuré",
|
||||||
|
"notConfigured": "Non configuré",
|
||||||
|
"configuredSection": "Configurés",
|
||||||
|
"notConfiguredSection": "Non configurés",
|
||||||
|
"showMore": "Afficher {{count}} de plus",
|
||||||
|
"showLess": "Afficher moins",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "Saisir l'API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "Laisser vide pour conserver la key actuelle",
|
||||||
|
"configuredKeyHint": "Key configurée",
|
||||||
|
"apiBasePlaceholder": "Utiliser la valeur par défaut du fournisseur",
|
||||||
|
"apiKeyRequired": "Une API key est requise pour configurer ce fournisseur.",
|
||||||
|
"showApiKey": "Afficher l'API key",
|
||||||
|
"hideApiKey": "Masquer l'API key",
|
||||||
|
"noConfiguredProviders": "Aucun fournisseur configuré",
|
||||||
|
"configureFirst": "Configurez d'abord un fournisseur dans BYOK.",
|
||||||
|
"openByok": "Ouvrir BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Discussion {{id}}",
|
"fallbackTitle": "Discussion {{id}}",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "Sistem",
|
"section": "Sistem",
|
||||||
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
||||||
"restart": "Mulai ulang nanobot"
|
"restart": "Mulai ulang nanobot",
|
||||||
|
"restarting": "Memulai ulang..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
|
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "Obrolan baru",
|
"newChat": "Obrolan baru",
|
||||||
"recent": "Terbaru",
|
"recent": "Terbaru",
|
||||||
"refreshSessions": "Segarkan sesi",
|
"refreshSessions": "Segarkan sesi",
|
||||||
|
"settings": "Pengaturan",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Bahasa",
|
"label": "Bahasa",
|
||||||
"ariaLabel": "Ganti bahasa"
|
"ariaLabel": "Ganti bahasa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "Kembali ke obrolan",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Pengaturan",
|
||||||
|
"ariaLabel": "Bagian pengaturan"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "Umum",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "Antarmuka",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "Sistem"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "Tema",
|
||||||
|
"language": "Bahasa",
|
||||||
|
"provider": "Penyedia",
|
||||||
|
"model": "Model",
|
||||||
|
"restart": "Mulai ulang nanobot",
|
||||||
|
"configPath": "Path konfigurasi"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "Beralih antara tampilan terang dan gelap.",
|
||||||
|
"language": "Pilih bahasa yang digunakan WebUI.",
|
||||||
|
"provider": "Pilih penyedia untuk permintaan model baru.",
|
||||||
|
"model": "Atur nama model default yang digunakan nanobot.",
|
||||||
|
"configPath": "File konfigurasi gateway yang sedang digunakan."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "Terang",
|
||||||
|
"dark": "Gelap",
|
||||||
|
"notAvailable": "Tidak tersedia"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "Memuat pengaturan...",
|
||||||
|
"loadError": "Tidak dapat memuat pengaturan",
|
||||||
|
"unsaved": "Ada perubahan yang belum disimpan.",
|
||||||
|
"savedRestart": "Tersimpan. Mulai ulang nanobot untuk menerapkan."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Simpan",
|
||||||
|
"saving": "Menyimpan",
|
||||||
|
"edit": "Edit",
|
||||||
|
"cancel": "Batal"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
|
||||||
|
"configured": "Terkonfigurasi",
|
||||||
|
"notConfigured": "Belum dikonfigurasi",
|
||||||
|
"configuredSection": "Terkonfigurasi",
|
||||||
|
"notConfiguredSection": "Belum dikonfigurasi",
|
||||||
|
"showMore": "Tampilkan {{count}} lagi",
|
||||||
|
"showLess": "Tampilkan lebih sedikit",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "Masukkan API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "Kosongkan untuk mempertahankan key saat ini",
|
||||||
|
"configuredKeyHint": "Key terkonfigurasi",
|
||||||
|
"apiBasePlaceholder": "Gunakan default provider",
|
||||||
|
"apiKeyRequired": "API key diperlukan untuk mengonfigurasi provider ini.",
|
||||||
|
"showApiKey": "Tampilkan API key",
|
||||||
|
"hideApiKey": "Sembunyikan API key",
|
||||||
|
"noConfiguredProviders": "Belum ada provider terkonfigurasi",
|
||||||
|
"configureFirst": "Konfigurasikan provider di BYOK terlebih dahulu.",
|
||||||
|
"openByok": "Buka BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Obrolan {{id}}",
|
"fallbackTitle": "Obrolan {{id}}",
|
||||||
"loading": "Memuat…",
|
"loading": "Memuat…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "システム",
|
"section": "システム",
|
||||||
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
|
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
|
||||||
"restart": "nanobot を再起動"
|
"restart": "nanobot を再起動",
|
||||||
|
"restarting": "再起動中..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "{{seconds}} 秒で再起動が完了しました。"
|
"completed": "{{seconds}} 秒で再起動が完了しました。"
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "新しいチャット",
|
"newChat": "新しいチャット",
|
||||||
"recent": "最近のチャット",
|
"recent": "最近のチャット",
|
||||||
"refreshSessions": "セッションを更新",
|
"refreshSessions": "セッションを更新",
|
||||||
|
"settings": "設定",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "言語",
|
"label": "言語",
|
||||||
"ariaLabel": "言語を変更"
|
"ariaLabel": "言語を変更"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "チャットに戻る",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "設定",
|
||||||
|
"ariaLabel": "設定セクション"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "一般",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "インターフェース",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "システム"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "テーマ",
|
||||||
|
"language": "言語",
|
||||||
|
"provider": "プロバイダー",
|
||||||
|
"model": "モデル",
|
||||||
|
"restart": "nanobot を再起動",
|
||||||
|
"configPath": "設定パス"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "ライト表示とダーク表示を切り替えます。",
|
||||||
|
"language": "WebUI で使用する言語を選択します。",
|
||||||
|
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
|
||||||
|
"model": "nanobot が既定で使用するモデル名を設定します。",
|
||||||
|
"configPath": "現在ゲートウェイが使用している設定ファイルです。"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "ライト",
|
||||||
|
"dark": "ダーク",
|
||||||
|
"notAvailable": "利用不可"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "設定を読み込んでいます...",
|
||||||
|
"loadError": "設定を読み込めませんでした",
|
||||||
|
"unsaved": "未保存の変更があります。",
|
||||||
|
"savedRestart": "保存しました。反映するには nanobot を再起動してください。"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中",
|
||||||
|
"edit": "編集",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
|
||||||
|
"configured": "設定済み",
|
||||||
|
"notConfigured": "未設定",
|
||||||
|
"configuredSection": "設定済み",
|
||||||
|
"notConfiguredSection": "未設定",
|
||||||
|
"showMore": "さらに {{count}} 件表示",
|
||||||
|
"showLess": "折りたたむ",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "API key を入力",
|
||||||
|
"apiKeyConfiguredPlaceholder": "空欄のままなら現在の key を保持",
|
||||||
|
"configuredKeyHint": "設定済み key",
|
||||||
|
"apiBasePlaceholder": "provider の既定値を使用",
|
||||||
|
"apiKeyRequired": "この provider を設定するには API key が必要です。",
|
||||||
|
"showApiKey": "API key を表示",
|
||||||
|
"hideApiKey": "API key を隠す",
|
||||||
|
"noConfiguredProviders": "設定済み provider がありません",
|
||||||
|
"configureFirst": "先に BYOK で provider を設定してください。",
|
||||||
|
"openByok": "BYOK を開く"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "チャット {{id}}",
|
"fallbackTitle": "チャット {{id}}",
|
||||||
"loading": "読み込み中…",
|
"loading": "読み込み中…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "시스템",
|
"section": "시스템",
|
||||||
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
|
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
|
||||||
"restart": "nanobot 다시 시작"
|
"restart": "nanobot 다시 시작",
|
||||||
|
"restarting": "다시 시작 중..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "{{seconds}}초 만에 다시 시작되었습니다."
|
"completed": "{{seconds}}초 만에 다시 시작되었습니다."
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "새 채팅",
|
"newChat": "새 채팅",
|
||||||
"recent": "최근 대화",
|
"recent": "최근 대화",
|
||||||
"refreshSessions": "세션 새로고침",
|
"refreshSessions": "세션 새로고침",
|
||||||
|
"settings": "설정",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "언어",
|
"label": "언어",
|
||||||
"ariaLabel": "언어 변경"
|
"ariaLabel": "언어 변경"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "채팅으로 돌아가기",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "설정",
|
||||||
|
"ariaLabel": "설정 섹션"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "일반",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "인터페이스",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "시스템"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "테마",
|
||||||
|
"language": "언어",
|
||||||
|
"provider": "제공자",
|
||||||
|
"model": "모델",
|
||||||
|
"restart": "nanobot 재시작",
|
||||||
|
"configPath": "설정 경로"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
|
||||||
|
"language": "WebUI에서 사용할 언어를 선택합니다.",
|
||||||
|
"provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
|
||||||
|
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
|
||||||
|
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "라이트",
|
||||||
|
"dark": "다크",
|
||||||
|
"notAvailable": "사용할 수 없음"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "설정을 불러오는 중...",
|
||||||
|
"loadError": "설정을 불러올 수 없습니다",
|
||||||
|
"unsaved": "저장되지 않은 변경 사항이 있습니다.",
|
||||||
|
"savedRestart": "저장되었습니다. 적용하려면 nanobot을 재시작하세요."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "저장",
|
||||||
|
"saving": "저장 중",
|
||||||
|
"edit": "편집",
|
||||||
|
"cancel": "취소"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
|
||||||
|
"configured": "설정됨",
|
||||||
|
"notConfigured": "설정 안 됨",
|
||||||
|
"configuredSection": "설정됨",
|
||||||
|
"notConfiguredSection": "설정 안 됨",
|
||||||
|
"showMore": "{{count}}개 더 보기",
|
||||||
|
"showLess": "접기",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "API key 입력",
|
||||||
|
"apiKeyConfiguredPlaceholder": "비워 두면 현재 key 유지",
|
||||||
|
"configuredKeyHint": "설정된 key",
|
||||||
|
"apiBasePlaceholder": "provider 기본값 사용",
|
||||||
|
"apiKeyRequired": "이 provider를 설정하려면 API key가 필요합니다.",
|
||||||
|
"showApiKey": "API key 표시",
|
||||||
|
"hideApiKey": "API key 숨기기",
|
||||||
|
"noConfiguredProviders": "설정된 provider가 없습니다",
|
||||||
|
"configureFirst": "먼저 BYOK에서 provider를 설정하세요.",
|
||||||
|
"openByok": "BYOK 열기"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "채팅 {{id}}",
|
"fallbackTitle": "채팅 {{id}}",
|
||||||
"loading": "불러오는 중…",
|
"loading": "불러오는 중…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "Hệ thống",
|
"section": "Hệ thống",
|
||||||
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
||||||
"restart": "Khởi động lại nanobot"
|
"restart": "Khởi động lại nanobot",
|
||||||
|
"restarting": "Đang khởi động lại..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
|
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "Cuộc trò chuyện mới",
|
"newChat": "Cuộc trò chuyện mới",
|
||||||
"recent": "Gần đây",
|
"recent": "Gần đây",
|
||||||
"refreshSessions": "Làm mới phiên",
|
"refreshSessions": "Làm mới phiên",
|
||||||
|
"settings": "Cài đặt",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Ngôn ngữ",
|
"label": "Ngôn ngữ",
|
||||||
"ariaLabel": "Đổi ngôn ngữ"
|
"ariaLabel": "Đổi ngôn ngữ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "Quay lại trò chuyện",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Cài đặt",
|
||||||
|
"ariaLabel": "Các mục cài đặt"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "Chung",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "Giao diện",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "Hệ thống"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "Giao diện",
|
||||||
|
"language": "Ngôn ngữ",
|
||||||
|
"provider": "Nhà cung cấp",
|
||||||
|
"model": "Mô hình",
|
||||||
|
"restart": "Khởi động lại nanobot",
|
||||||
|
"configPath": "Đường dẫn cấu hình"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "Chuyển giữa giao diện sáng và tối.",
|
||||||
|
"language": "Chọn ngôn ngữ dùng trong WebUI.",
|
||||||
|
"provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.",
|
||||||
|
"model": "Đặt tên mô hình mặc định mà nanobot sử dụng.",
|
||||||
|
"configPath": "Tệp cấu hình gateway hiện đang dùng."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "Sáng",
|
||||||
|
"dark": "Tối",
|
||||||
|
"notAvailable": "Không khả dụng"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "Đang tải cài đặt...",
|
||||||
|
"loadError": "Không thể tải cài đặt",
|
||||||
|
"unsaved": "Có thay đổi chưa lưu.",
|
||||||
|
"savedRestart": "Đã lưu. Khởi động lại nanobot để áp dụng."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Lưu",
|
||||||
|
"saving": "Đang lưu",
|
||||||
|
"edit": "Sửa",
|
||||||
|
"cancel": "Hủy"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
|
||||||
|
"configured": "Đã cấu hình",
|
||||||
|
"notConfigured": "Chưa cấu hình",
|
||||||
|
"configuredSection": "Đã cấu hình",
|
||||||
|
"notConfiguredSection": "Chưa cấu hình",
|
||||||
|
"showMore": "Hiển thị thêm {{count}}",
|
||||||
|
"showLess": "Thu gọn",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "Nhập API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "Để trống để giữ key hiện tại",
|
||||||
|
"configuredKeyHint": "Key đã cấu hình",
|
||||||
|
"apiBasePlaceholder": "Dùng mặc định của provider",
|
||||||
|
"apiKeyRequired": "Cần API key để cấu hình provider này.",
|
||||||
|
"showApiKey": "Hiển thị API key",
|
||||||
|
"hideApiKey": "Ẩn API key",
|
||||||
|
"noConfiguredProviders": "Chưa có provider đã cấu hình",
|
||||||
|
"configureFirst": "Hãy cấu hình provider trong BYOK trước.",
|
||||||
|
"openByok": "Mở BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "Trò chuyện {{id}}",
|
"fallbackTitle": "Trò chuyện {{id}}",
|
||||||
"loading": "Đang tải…",
|
"loading": "Đang tải…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "系统",
|
"section": "系统",
|
||||||
"restartHint": "重启 nanobot 以应用运行时更改。",
|
"restartHint": "重启 nanobot 以应用运行时更改。",
|
||||||
"restart": "重启 nanobot"
|
"restart": "重启 nanobot",
|
||||||
|
"restarting": "正在重启..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "重启已完成,用时 {{seconds}} 秒。"
|
"completed": "重启已完成,用时 {{seconds}} 秒。"
|
||||||
@ -44,6 +45,75 @@
|
|||||||
"ariaLabel": "切换语言"
|
"ariaLabel": "切换语言"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "返回对话",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "设置",
|
||||||
|
"ariaLabel": "设置分区"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "通用",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "界面",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "系统"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "主题",
|
||||||
|
"language": "语言",
|
||||||
|
"provider": "提供商",
|
||||||
|
"model": "模型",
|
||||||
|
"restart": "重启 nanobot",
|
||||||
|
"configPath": "配置路径"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "在浅色和深色外观之间切换。",
|
||||||
|
"language": "选择 WebUI 使用的语言。",
|
||||||
|
"provider": "选择新模型请求使用的服务提供商。",
|
||||||
|
"model": "设置 nanobot 默认使用的模型名称。",
|
||||||
|
"configPath": "当前网关正在使用的配置文件。"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "深色",
|
||||||
|
"notAvailable": "不可用"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "正在加载设置...",
|
||||||
|
"loadError": "无法加载设置",
|
||||||
|
"unsaved": "有未保存的更改。",
|
||||||
|
"savedRestart": "已保存。重启 nanobot 后生效。"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中",
|
||||||
|
"edit": "编辑",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "自带 provider key。Nanobot 会从当前 config 读取这些值,只有已配置的 provider 才能在通用设置里选择。",
|
||||||
|
"configured": "已配置",
|
||||||
|
"notConfigured": "未配置",
|
||||||
|
"configuredSection": "已配置",
|
||||||
|
"notConfiguredSection": "未配置",
|
||||||
|
"showMore": "再显示 {{count}} 个",
|
||||||
|
"showLess": "收起",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "输入 API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "留空则保留当前 key",
|
||||||
|
"configuredKeyHint": "已配置的 key",
|
||||||
|
"apiBasePlaceholder": "使用 provider 默认地址",
|
||||||
|
"apiKeyRequired": "需要 API key 才能配置此 provider。",
|
||||||
|
"showApiKey": "显示 API key",
|
||||||
|
"hideApiKey": "隐藏 API key",
|
||||||
|
"noConfiguredProviders": "没有已配置的 provider",
|
||||||
|
"configureFirst": "请先在 BYOK 里配置 provider。",
|
||||||
|
"openByok": "打开 BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "对话 {{id}}",
|
"fallbackTitle": "对话 {{id}}",
|
||||||
"loading": "加载中…",
|
"loading": "加载中…",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"section": "系統",
|
"section": "系統",
|
||||||
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
|
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
|
||||||
"restart": "重新啟動 nanobot"
|
"restart": "重新啟動 nanobot",
|
||||||
|
"restarting": "正在重新啟動..."
|
||||||
},
|
},
|
||||||
"restart": {
|
"restart": {
|
||||||
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
|
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
|
||||||
@ -31,11 +32,81 @@
|
|||||||
"newChat": "新增對話",
|
"newChat": "新增對話",
|
||||||
"recent": "最近對話",
|
"recent": "最近對話",
|
||||||
"refreshSessions": "重新整理會話",
|
"refreshSessions": "重新整理會話",
|
||||||
|
"settings": "設定",
|
||||||
"language": {
|
"language": {
|
||||||
"label": "語言",
|
"label": "語言",
|
||||||
"ariaLabel": "切換語言"
|
"ariaLabel": "切換語言"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"backToChat": "返回對話",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "設定",
|
||||||
|
"ariaLabel": "設定分區"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"general": "一般",
|
||||||
|
"byok": "BYOK"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"interface": "介面",
|
||||||
|
"ai": "AI",
|
||||||
|
"system": "系統"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"theme": "主題",
|
||||||
|
"language": "語言",
|
||||||
|
"provider": "提供者",
|
||||||
|
"model": "模型",
|
||||||
|
"restart": "重新啟動 nanobot",
|
||||||
|
"configPath": "設定檔路徑"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"theme": "在淺色與深色外觀之間切換。",
|
||||||
|
"language": "選擇 WebUI 使用的語言。",
|
||||||
|
"provider": "選擇新模型請求使用的服務提供者。",
|
||||||
|
"model": "設定 nanobot 預設使用的模型名稱。",
|
||||||
|
"configPath": "目前閘道正在使用的設定檔。"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"light": "淺色",
|
||||||
|
"dark": "深色",
|
||||||
|
"notAvailable": "不可用"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "正在載入設定...",
|
||||||
|
"loadError": "無法載入設定",
|
||||||
|
"unsaved": "有未儲存的變更。",
|
||||||
|
"savedRestart": "已儲存。重新啟動 nanobot 後生效。"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "儲存",
|
||||||
|
"saving": "儲存中",
|
||||||
|
"edit": "編輯",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
|
||||||
|
"configured": "已設定",
|
||||||
|
"notConfigured": "未設定",
|
||||||
|
"configuredSection": "已設定",
|
||||||
|
"notConfiguredSection": "未設定",
|
||||||
|
"showMore": "再顯示 {{count}} 個",
|
||||||
|
"showLess": "收合",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"apiBase": "API base",
|
||||||
|
"apiKeyPlaceholder": "輸入 API key",
|
||||||
|
"apiKeyConfiguredPlaceholder": "留空則保留目前 key",
|
||||||
|
"configuredKeyHint": "已設定的 key",
|
||||||
|
"apiBasePlaceholder": "使用 provider 預設地址",
|
||||||
|
"apiKeyRequired": "需要 API key 才能設定此 provider。",
|
||||||
|
"showApiKey": "顯示 API key",
|
||||||
|
"hideApiKey": "隱藏 API key",
|
||||||
|
"noConfiguredProviders": "沒有已設定的 provider",
|
||||||
|
"configureFirst": "請先在 BYOK 設定 provider。",
|
||||||
|
"openByok": "開啟 BYOK"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"fallbackTitle": "對話 {{id}}",
|
"fallbackTitle": "對話 {{id}}",
|
||||||
"loading": "載入中…",
|
"loading": "載入中…",
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
|
import type {
|
||||||
|
ChatSummary,
|
||||||
|
ProviderSettingsUpdate,
|
||||||
|
SettingsPayload,
|
||||||
|
SettingsUpdate,
|
||||||
|
SlashCommand,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@ -147,3 +153,18 @@ export async function updateSettings(
|
|||||||
if (update.provider !== undefined) query.set("provider", update.provider);
|
if (update.provider !== undefined) query.set("provider", update.provider);
|
||||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProviderSettings(
|
||||||
|
token: string,
|
||||||
|
update: ProviderSettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<SettingsPayload> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("provider", update.provider);
|
||||||
|
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
||||||
|
if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
|
||||||
|
return request<SettingsPayload>(
|
||||||
|
`${base}/api/settings/provider/update?${query}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -77,6 +77,10 @@ export interface SettingsPayload {
|
|||||||
providers: Array<{
|
providers: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
configured: boolean;
|
||||||
|
api_key_hint?: string | null;
|
||||||
|
api_base?: string | null;
|
||||||
|
default_api_base?: string | null;
|
||||||
}>;
|
}>;
|
||||||
runtime: {
|
runtime: {
|
||||||
config_path: string;
|
config_path: string;
|
||||||
@ -89,6 +93,12 @@ export interface SettingsUpdate {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderSettingsUpdate {
|
||||||
|
provider: string;
|
||||||
|
apiKey?: string;
|
||||||
|
apiBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
command: string;
|
command: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
fetchSessionMessages,
|
fetchSessionMessages,
|
||||||
listSessions,
|
listSessions,
|
||||||
listSlashCommands,
|
listSlashCommands,
|
||||||
|
updateProviderSettings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
@ -55,6 +56,21 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes provider settings updates without returning secrets", async () => {
|
||||||
|
await updateProviderSettings("tok", {
|
||||||
|
provider: "openrouter",
|
||||||
|
apiKey: "sk-or-test",
|
||||||
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/provider/update?provider=openrouter&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps generated session titles from the sessions list", async () => {
|
it("maps generated session titles from the sessions list", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@ -155,7 +155,7 @@ describe("App layout", () => {
|
|||||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
it("opens the Cursor-style settings view from the header", async () => {
|
it("opens the settings view from the sidebar footer", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
key: "websocket:chat-a",
|
key: "websocket:chat-a",
|
||||||
@ -181,8 +181,13 @@ describe("App layout", () => {
|
|||||||
has_api_key: true,
|
has_api_key: true,
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
{ name: "auto", label: "Auto" },
|
{ name: "openai", label: "OpenAI", configured: true },
|
||||||
{ name: "openai", label: "OpenAI" },
|
{
|
||||||
|
name: "openrouter",
|
||||||
|
label: "OpenRouter",
|
||||||
|
configured: false,
|
||||||
|
default_api_base: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
runtime: {
|
runtime: {
|
||||||
config_path: "/tmp/config.json",
|
config_path: "/tmp/config.json",
|
||||||
@ -198,11 +203,24 @@ describe("App layout", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
|
||||||
|
expect(document.title).toBe("Settings · nanobot");
|
||||||
|
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
|
||||||
|
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
|
||||||
|
expect(within(settingsNav).getByRole("button", { name: "General" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
expect(within(settingsNav).getByRole("button", { name: "BYOK" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("AI")).toBeInTheDocument();
|
expect(screen.getByText("AI")).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" }));
|
||||||
|
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters sidebar sessions through the lightweight search row", async () => {
|
it("filters sidebar sessions through the lightweight search row", async () => {
|
||||||
@ -285,7 +303,7 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
|
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
|
expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument();
|
||||||
|
|
||||||
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { resources } from "@/i18n";
|
|||||||
|
|
||||||
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
||||||
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
|
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
|
||||||
|
const SETTINGS_NAV_KEYS = ["general", "byok"];
|
||||||
|
|
||||||
describe("webui i18n", () => {
|
describe("webui i18n", () => {
|
||||||
it("switches UI copy and document locale through the language switcher", async () => {
|
it("switches UI copy and document locale through the language switcher", async () => {
|
||||||
@ -62,4 +63,28 @@ describe("webui i18n", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps settings navigation localized for every registered locale", () => {
|
||||||
|
for (const resource of Object.values(resources)) {
|
||||||
|
const common = resource.common;
|
||||||
|
expect(common.app.system.restarting).toBeTruthy();
|
||||||
|
expect(common.sidebar.settings).toBeTruthy();
|
||||||
|
expect(common.settings.sidebar.title).toBeTruthy();
|
||||||
|
expect(common.settings.backToChat).toBeTruthy();
|
||||||
|
for (const key of SETTINGS_NAV_KEYS) {
|
||||||
|
expect(common.settings.nav[key as keyof typeof common.settings.nav]).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(common.settings.rows.theme).toBeTruthy();
|
||||||
|
expect(common.settings.status.loading).toBeTruthy();
|
||||||
|
expect(common.settings.actions.save).toBeTruthy();
|
||||||
|
expect(common.settings.actions.edit).toBeTruthy();
|
||||||
|
expect(common.settings.byok.configured).toBeTruthy();
|
||||||
|
expect(common.settings.byok.configuredSection).toBeTruthy();
|
||||||
|
expect(common.settings.byok.showMore).toBeTruthy();
|
||||||
|
expect(common.settings.byok.apiKeyRequired).toBeTruthy();
|
||||||
|
expect(common.settings.byok.showApiKey).toBeTruthy();
|
||||||
|
expect(common.settings.byok.hideApiKey).toBeTruthy();
|
||||||
|
expect(common.settings.byok.configuredKeyHint).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user