refactor: split i18n catalogs into domain modules, rework dialog components
In-progress work committed to unblock the config import/export plan: - i18n: new changePassword/modelConfig/imageModels/systemConfig catalogs (zh+en) - AccountDialog/ChangePasswordDialog/ConfigDialog/ImageModelsDialog rewired - RegistryEditor/ImageModelsEditor updated for the new catalogs - system-config tabs restyled; page/layout follow-ons
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
||||
@@ -82,7 +83,11 @@ export default async function RootLayout({
|
||||
suppressHydrationWarning
|
||||
className={inter.variable}
|
||||
>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
<Script
|
||||
id="theme-init"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: themeScript }}
|
||||
/>
|
||||
<NextIntlClientProvider locale={locale}>
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider>
|
||||
|
||||
+30
-22
@@ -387,21 +387,6 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
|
||||
<HealthIndicator />
|
||||
<LocaleSwitcher />
|
||||
<ThemeToggle />
|
||||
{authEnabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={signOut}
|
||||
aria-label={t("signOut")}
|
||||
title={t("signOut")}
|
||||
className="size-8"
|
||||
>
|
||||
<LogOut
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -427,21 +412,30 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("configuration")}
|
||||
title={t("configuration")}
|
||||
aria-label={authEnabled ? t("account") : t("configuration")}
|
||||
title={authEnabled ? t("account") : t("configuration")}
|
||||
className="size-8"
|
||||
>
|
||||
<Settings
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{authEnabled ? (
|
||||
<UserRound
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Settings
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-52"
|
||||
>
|
||||
<DropdownMenuLabel>{t("configuration")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{authEnabled ? t("account") : t("configuration")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{authEnabled && (
|
||||
<>
|
||||
@@ -477,6 +471,20 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
{authEnabled && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() => void signOut()}
|
||||
>
|
||||
<LogOut aria-hidden="true" />
|
||||
{t("signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,6 +19,8 @@ interface AccountDialogProps {
|
||||
}
|
||||
|
||||
export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
const t = useTranslations("dialogs.account");
|
||||
const tCommon = useTranslations("common");
|
||||
const [account, setAccount] = useState<{
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
@@ -45,9 +48,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
} | null;
|
||||
if (!response.ok || typeof body?.username !== "string") {
|
||||
throw new Error(
|
||||
typeof body?.error === "string"
|
||||
? body.error
|
||||
: "Account information is unavailable."
|
||||
typeof body?.error === "string" ? body.error : t("unavailable")
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -60,11 +61,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Account information is unavailable."
|
||||
);
|
||||
setError(reason instanceof Error ? reason.message : t("unavailable"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -74,7 +71,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, t]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -83,19 +80,19 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>User information</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your signed-in account for this workspace.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<section
|
||||
aria-label="Account details"
|
||||
aria-label={t("detailsLabel")}
|
||||
className="rounded-md border border-border px-4 py-3"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">Username</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("username")}
|
||||
</p>
|
||||
{loading || (!error && account === null) ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Loading account...
|
||||
{t("loading")}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p
|
||||
@@ -108,7 +105,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
<p className="mt-1 break-words text-sm font-medium text-foreground">
|
||||
{account.username}
|
||||
<span className="ml-2 rounded bg-accent px-1.5 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
{account.role}
|
||||
{account.role === "admin" ? t("roles.admin") : t("roles.user")}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
@@ -121,7 +118,7 @@ export function AccountDialog({ open, onOpenChange }: AccountDialogProps) {
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
{tCommon("close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,6 +23,7 @@ export function ChangePasswordDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ChangePasswordDialogProps) {
|
||||
const t = useTranslations("changePassword");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@@ -58,12 +60,12 @@ export function ChangePasswordDialog({
|
||||
error?: string;
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "Unable to change the password.");
|
||||
throw new Error(body.error || t("failed"));
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Unable to change the password."
|
||||
reason instanceof Error ? reason.message : t("failed")
|
||||
);
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -73,15 +75,12 @@ export function ChangePasswordDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Changing the password signs out every existing session except this
|
||||
one.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form method="post" onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="current-password">Current password</Label>
|
||||
<Label htmlFor="current-password">{t("currentPassword")}</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
@@ -94,7 +93,7 @@ export function ChangePasswordDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-password">New password</Label>
|
||||
<Label htmlFor="new-password">{t("newPassword")}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -107,7 +106,7 @@ export function ChangePasswordDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm-password">Confirm new password</Label>
|
||||
<Label htmlFor="confirm-password">{t("confirmPassword")}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
@@ -131,10 +130,10 @@ export function ChangePasswordDialog({
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Password"}
|
||||
{saving ? t("saving") : t("submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,6 +19,7 @@ interface ConfigDialogProps {
|
||||
}
|
||||
|
||||
export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
|
||||
const t = useTranslations("modelConfig");
|
||||
// The hook owns the UI-preference localStorage round-trip.
|
||||
const { value: collapseAgentActions, setValue: setCollapseAgentActions } =
|
||||
useCollapseAgentActions();
|
||||
@@ -34,11 +36,8 @@ export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Model registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage providers, models, credentials and defaults. Admin only;
|
||||
every save is validated by the backend.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && <RegistryEditor />}
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -47,18 +46,18 @@ export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
|
||||
type="checkbox"
|
||||
checked={collapseAgentActions}
|
||||
onChange={(e) => setCollapseAgentActions(e.target.checked)}
|
||||
aria-label="Collapse agent actions by default"
|
||||
aria-label={t("collapseAgentActions")}
|
||||
className="mt-1 size-4 rounded border-border accent-[var(--brand)]"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="collapseAgentActions"
|
||||
className="text-sm font-normal leading-snug"
|
||||
>
|
||||
Collapse agent actions by default
|
||||
{t("collapseAgentActions")}
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t("close")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +17,7 @@ interface ImageModelsDialogProps {
|
||||
}
|
||||
|
||||
export function ImageModelsDialog({ open, onOpenChange }: ImageModelsDialogProps) {
|
||||
const t = useTranslations("imageModels");
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -28,15 +30,12 @@ export function ImageModelsDialog({ open, onOpenChange }: ImageModelsDialogProps
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Image models</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage dedicated image-generation models (stored in config.yaml).
|
||||
Admin only.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && <ImageModelsEditor />}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t("close")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
@@ -80,7 +81,7 @@ function toDraftState(settings: ImageGenerationSettings): DraftState {
|
||||
|
||||
type ParsedParams =
|
||||
| { ok: true; value: Record<string, unknown> }
|
||||
| { ok: false; message: string };
|
||||
| { ok: false; code: "notObject" | "invalidJson" };
|
||||
|
||||
function parseParams(text: string): ParsedParams {
|
||||
const trimmed = text.trim();
|
||||
@@ -88,11 +89,11 @@ function parseParams(text: string): ParsedParams {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, message: "params must be a JSON object" };
|
||||
return { ok: false, code: "notObject" };
|
||||
}
|
||||
return { ok: true, value: parsed as Record<string, unknown> };
|
||||
} catch {
|
||||
return { ok: false, message: "invalid JSON" };
|
||||
return { ok: false, code: "invalidJson" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +138,11 @@ function ImageModelCard({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [outcome, setOutcome] = useState<TestOutcome | null>(null);
|
||||
const t = useTranslations("imageModels");
|
||||
const paramsResult = parseParams(model.paramsText);
|
||||
const paramsError = paramsResult.ok ? null : paramsResult.message;
|
||||
const paramsError = paramsResult.ok
|
||||
? null
|
||||
: t(`card.paramsErrors.${paramsResult.code}`);
|
||||
|
||||
const runTest = async () => {
|
||||
if (!paramsResult.ok) return;
|
||||
@@ -158,15 +162,21 @@ function ImageModelCard({
|
||||
});
|
||||
setOutcome({ ok: true, latencyMs: result.latency_ms });
|
||||
toast.success(
|
||||
`${model.id || "Image model"} passed the test in ${(result.latency_ms / 1000).toFixed(1)} s.`
|
||||
t("card.testPassed", {
|
||||
id: model.id || t("card.unnamed"),
|
||||
seconds: (result.latency_ms / 1000).toFixed(1),
|
||||
})
|
||||
);
|
||||
} catch (reason) {
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : "Test failed.";
|
||||
reason instanceof Error ? reason.message : t("card.testFailedGeneric");
|
||||
setOutcome({ ok: false, message });
|
||||
errorToast(
|
||||
"models.image-test",
|
||||
`${model.id || "Image model"} failed the test: ${message}`
|
||||
t("card.testFailedToast", {
|
||||
id: model.id || t("card.unnamed"),
|
||||
message,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@@ -177,7 +187,7 @@ function ImageModelCard({
|
||||
<div className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{model.id || "(new model)"}
|
||||
{model.id || t("card.newModel")}
|
||||
</span>
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{model.provider}
|
||||
@@ -191,7 +201,7 @@ function ImageModelCard({
|
||||
onChange({ ...model, enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
enabled
|
||||
{t("card.enabled")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -199,34 +209,33 @@ function ImageModelCard({
|
||||
size="sm"
|
||||
disabled={testing || paramsError !== null}
|
||||
title={
|
||||
paramsError
|
||||
? "Fix the invalid params JSON before testing."
|
||||
: undefined
|
||||
paramsError ? t("card.fixParamsBeforeTesting") : undefined
|
||||
}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
{testing ? t("card.testing") : t("card.test")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRemove}>
|
||||
Remove
|
||||
{t("card.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
{outcome &&
|
||||
(outcome.ok ? (
|
||||
<p className="rounded-md border border-emerald-600/30 bg-emerald-600/5 px-2 py-1 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
Test passed in {(outcome.latencyMs / 1000).toFixed(1)} s — the
|
||||
provider completed a real generation with these settings.
|
||||
{t("card.testPassedBanner", {
|
||||
seconds: (outcome.latencyMs / 1000).toFixed(1),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
|
||||
>
|
||||
Test failed: {outcome.message}
|
||||
{t("card.testFailedBanner", { message: outcome.message })}
|
||||
</p>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label="Model id">
|
||||
<Field label={t("card.modelId")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -234,7 +243,7 @@ function ImageModelCard({
|
||||
onChange={(event) => onChange({ ...model, id: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name">
|
||||
<Field label={t("card.displayName")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -244,7 +253,7 @@ function ImageModelCard({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider">
|
||||
<Field label={t("card.provider")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={model.provider}
|
||||
@@ -260,7 +269,7 @@ function ImageModelCard({
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Base URL">
|
||||
<Field label={t("card.baseUrl")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -272,7 +281,7 @@ function ImageModelCard({
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label="Default size">
|
||||
<Field label={t("card.defaultSize")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={model.default_size}
|
||||
@@ -290,7 +299,7 @@ function ImageModelCard({
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Default quality">
|
||||
<Field label={t("card.defaultQuality")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -302,11 +311,15 @@ function ImageModelCard({
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="API Key"
|
||||
label={t("card.apiKey")}
|
||||
hint={
|
||||
model.api_key_configured
|
||||
? `configured${model.api_key_hint ? ` (${model.api_key_hint})` : ""} — leave blank to keep`
|
||||
: "paste your API key"
|
||||
? model.api_key_hint
|
||||
? t("card.apiKeyConfiguredWithHint", {
|
||||
hint: model.api_key_hint,
|
||||
})
|
||||
: t("card.apiKeyConfiguredNoHint")
|
||||
: t("card.apiKeyNotConfigured")
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
@@ -322,7 +335,7 @@ function ImageModelCard({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
aria-label={showKey ? t("card.hideApiKey") : t("card.showApiKey")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowKey((current) => !current)}
|
||||
>
|
||||
@@ -331,7 +344,7 @@ function ImageModelCard({
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Params (JSON object, optional)">
|
||||
<Field label={t("card.paramsLabel")}>
|
||||
<textarea
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
rows={2}
|
||||
@@ -345,7 +358,7 @@ function ImageModelCard({
|
||||
</Field>
|
||||
{paramsError && (
|
||||
<p role="alert" className="text-[11px] text-destructive">
|
||||
Params: {paramsError}
|
||||
{t("card.paramsErrorPrefix", { message: paramsError })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -358,6 +371,7 @@ export function ImageModelsEditor() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const t = useTranslations("imageModels");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
@@ -368,16 +382,14 @@ export function ImageModelsEditor() {
|
||||
setDraft(toDraftState(settings));
|
||||
} catch (reason) {
|
||||
const message =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Failed to load image models.";
|
||||
reason instanceof Error ? reason.message : t("loadFailed");
|
||||
if (message.includes("(403)")) {
|
||||
setForbidden(true);
|
||||
} else {
|
||||
setLoadError(message);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -394,7 +406,7 @@ export function ImageModelsEditor() {
|
||||
if (!draft) return;
|
||||
const payload = buildPayload(draft);
|
||||
if (!payload) {
|
||||
toast.error("Fix the invalid params JSON before saving.");
|
||||
toast.error(t("fixParamsBeforeSaving"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -402,21 +414,21 @@ export function ImageModelsEditor() {
|
||||
const saved = await putImageGeneration(payload);
|
||||
setData(saved);
|
||||
setDraft(toDraftState(saved));
|
||||
toast.success("Image models saved.");
|
||||
toast.success(t("saved"));
|
||||
} catch (reason) {
|
||||
errorToast(
|
||||
"models.image-save",
|
||||
reason instanceof Error ? reason.message : "Failed to save image models."
|
||||
reason instanceof Error ? reason.message : t("saveFailed")
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [draft]);
|
||||
}, [draft, t]);
|
||||
|
||||
if (forbidden) {
|
||||
return (
|
||||
<p className="rounded-md border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Image model configuration requires an admin account.
|
||||
{t("forbidden")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -430,13 +442,13 @@ export function ImageModelsEditor() {
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
Retry
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data || !draft) {
|
||||
return <p className="text-sm text-muted-foreground">Loading image models…</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
const defaultOptions = draft.models.map((model) => model.id);
|
||||
@@ -444,7 +456,7 @@ export function ImageModelsEditor() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-2 rounded-lg border border-border p-4 sm:grid-cols-2">
|
||||
<Field label="Default model">
|
||||
<Field label={t("defaultModel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={draft.default_model}
|
||||
@@ -452,7 +464,7 @@ export function ImageModelsEditor() {
|
||||
setDraft({ ...draft, default_model: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
<option value="">{t("none")}</option>
|
||||
{defaultOptions.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
@@ -466,7 +478,7 @@ export function ImageModelsEditor() {
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Timeout (s)">
|
||||
<Field label={t("timeoutSeconds")}>
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
@@ -484,7 +496,7 @@ export function ImageModelsEditor() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Models</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("modelsHeading")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -505,11 +517,11 @@ export function ImageModelsEditor() {
|
||||
})
|
||||
}
|
||||
>
|
||||
Add model
|
||||
{t("addModel")}
|
||||
</Button>
|
||||
</div>
|
||||
{draft.models.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No models yet.</p>
|
||||
<p className="text-xs text-muted-foreground">{t("empty")}</p>
|
||||
)}
|
||||
{draft.models.map((model, index) => (
|
||||
<ImageModelCard
|
||||
@@ -539,7 +551,7 @@ export function ImageModelsEditor() {
|
||||
onClick={() => void save()}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -547,16 +559,16 @@ export function ImageModelsEditor() {
|
||||
disabled={saving || !dirty}
|
||||
onClick={() => setDraft(toDraftState(data))}
|
||||
>
|
||||
Discard changes
|
||||
{t("discardChanges")}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<span className="rounded bg-[var(--color-warning)]/10 px-1.5 py-0.5 text-xs text-[var(--color-warning)]">
|
||||
unsaved changes
|
||||
{t("unsavedChanges")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
API keys are write-only: leave the field blank to keep the stored key.
|
||||
{t("apiKeyWriteOnlyNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
@@ -43,7 +44,10 @@ interface ApiErrorBody {
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
async function readApiError(response: Response): Promise<{
|
||||
async function readApiError(
|
||||
response: Response,
|
||||
fallbackMessage?: (status: number) => string
|
||||
): Promise<{
|
||||
message: string;
|
||||
code: string | null;
|
||||
details: Array<{ path?: string; code?: string }>;
|
||||
@@ -53,7 +57,8 @@ async function readApiError(response: Response): Promise<{
|
||||
message:
|
||||
typeof body?.message === "string"
|
||||
? body.message
|
||||
: `Request failed (${response.status}).`,
|
||||
: fallbackMessage?.(response.status) ??
|
||||
`Request failed (${response.status}).`,
|
||||
code: typeof body?.code === "string" ? body.code : null,
|
||||
details: Array.isArray(body?.details)
|
||||
? (body.details as Array<{ path?: string; code?: string }>)
|
||||
@@ -68,7 +73,8 @@ type PutRegistryResult =
|
||||
| { ok: false; status: number; error: ApiErrorInfo };
|
||||
|
||||
async function putRegistry(
|
||||
payload: PutModelRegistryRequest
|
||||
payload: PutModelRegistryRequest,
|
||||
fallbackMessage?: (status: number) => string
|
||||
): Promise<PutRegistryResult> {
|
||||
const response = await fetch("/api/model-registry", {
|
||||
method: "PUT",
|
||||
@@ -79,7 +85,7 @@ async function putRegistry(
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: await readApiError(response),
|
||||
error: await readApiError(response, fallbackMessage),
|
||||
};
|
||||
}
|
||||
return { ok: true, body: (await response.json()) as GetModelRegistryResponse };
|
||||
@@ -121,10 +127,15 @@ function NumberField({
|
||||
nullable?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("modelConfig");
|
||||
const hint = rule
|
||||
? [
|
||||
rule.minimum != null ? `min ${rule.minimum}` : null,
|
||||
rule.maximum != null ? `max ${rule.maximum}` : null,
|
||||
rule.minimum != null
|
||||
? t("registry.minHint", { value: rule.minimum })
|
||||
: null,
|
||||
rule.maximum != null
|
||||
? t("registry.maxHint", { value: rule.maximum })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || undefined
|
||||
@@ -139,7 +150,7 @@ function NumberField({
|
||||
max={rule?.maximum ?? undefined}
|
||||
step={rule?.value_type === "integer" ? 1 : "any"}
|
||||
disabled={disabled || rule?.supported === false}
|
||||
placeholder={nullable ? "inherit" : undefined}
|
||||
placeholder={nullable ? t("registry.inheritPlaceholder") : undefined}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === "" && nullable) {
|
||||
@@ -162,11 +173,12 @@ function AvailabilityBadge({
|
||||
data: GetModelRegistryResponse;
|
||||
ref_: ModelRef;
|
||||
}) {
|
||||
const t = useTranslations("modelConfig");
|
||||
const status = availabilityFor(data, ref_);
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
new
|
||||
{t("registry.statusNew")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -194,6 +206,7 @@ function ModelStatusDot({
|
||||
data: GetModelRegistryResponse;
|
||||
ref_: ModelRef;
|
||||
}) {
|
||||
const t = useTranslations("modelConfig");
|
||||
const status = availabilityFor(data, ref_);
|
||||
const state = status?.state ?? null;
|
||||
const color =
|
||||
@@ -207,7 +220,7 @@ function ModelStatusDot({
|
||||
return (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${color}`}
|
||||
title={`${ref_.model_key}: ${state ?? "new"}`}
|
||||
title={`${ref_.model_key}: ${state ?? t("registry.statusNew")}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -235,6 +248,7 @@ function ModelEditor({
|
||||
onRemove: () => void;
|
||||
onTest: () => void;
|
||||
}) {
|
||||
const t = useTranslations("modelConfig");
|
||||
const ref: ModelRef = { provider_id: provider.id, model_key: model.key };
|
||||
const rule = (name: string) => spec?.parameters[name] ?? null;
|
||||
const runtime = model.runtime;
|
||||
@@ -246,6 +260,14 @@ function ModelEditor({
|
||||
() => availabilityFor(data, ref) === null
|
||||
);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const capabilityLabels: Record<
|
||||
"tools" | "vision" | "structured_output",
|
||||
string
|
||||
> = {
|
||||
tools: t("registry.capabilityTools"),
|
||||
vision: t("registry.capabilityVision"),
|
||||
structured_output: t("registry.capabilityStructuredOutput"),
|
||||
};
|
||||
const capability = (key: "tools" | "vision" | "structured_output") => {
|
||||
const protocolAllows = spec?.protocol_capabilities[key] !== false;
|
||||
return (
|
||||
@@ -263,7 +285,7 @@ function ModelEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
{key.replace("_", " ")}
|
||||
{capabilityLabels[key]}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -274,7 +296,7 @@ function ModelEditor({
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Toggle parameters for ${model.key}`}
|
||||
aria-label={t("registry.toggleParameters", { key: model.key })}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
@@ -295,30 +317,26 @@ function ModelEditor({
|
||||
onChange({ ...model, enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
enabled
|
||||
{t("registry.enabled")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testing || dirty}
|
||||
title={
|
||||
dirty
|
||||
? "Save the registry before testing (tests only run against saved models)."
|
||||
: undefined
|
||||
}
|
||||
title={dirty ? t("registry.saveBeforeTesting") : undefined}
|
||||
onClick={onTest}
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
{testing ? t("registry.testing") : t("registry.test")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRemove}>
|
||||
Remove
|
||||
{t("registry.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label="Model key">
|
||||
<Field label={t("registry.modelKey")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -329,7 +347,7 @@ function ModelEditor({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name">
|
||||
<Field label={t("registry.displayName")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -339,7 +357,7 @@ function ModelEditor({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Upstream model id">
|
||||
<Field label={t("registry.upstreamModelId")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -352,13 +370,13 @@ function ModelEditor({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<NumberField
|
||||
label="Context window"
|
||||
label={t("registry.contextWindow")}
|
||||
value={runtime.context_window_tokens}
|
||||
nullable
|
||||
onChange={(value) => setRuntime({ context_window_tokens: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Max output tokens"
|
||||
label={t("registry.maxOutputTokens")}
|
||||
value={runtime.max_output_tokens}
|
||||
rule={rule("max_output_tokens")}
|
||||
onChange={(value) =>
|
||||
@@ -366,7 +384,11 @@ function ModelEditor({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label={rule("temperature")?.supported === false ? "Temperature (unsupported)" : "Temperature"}
|
||||
label={
|
||||
rule("temperature")?.supported === false
|
||||
? t("registry.temperatureUnsupported")
|
||||
: t("registry.temperature")
|
||||
}
|
||||
value={runtime.temperature}
|
||||
nullable
|
||||
rule={rule("temperature")}
|
||||
@@ -375,7 +397,7 @@ function ModelEditor({
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
{t("registry.capabilities")}
|
||||
</span>
|
||||
{capability("tools")}
|
||||
{capability("vision")}
|
||||
@@ -393,11 +415,11 @@ function ModelEditor({
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
Advanced
|
||||
{t("registry.advanced")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label="Limit mode">
|
||||
<Field label={t("registry.limitMode")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runtime.limit_mode}
|
||||
@@ -412,40 +434,40 @@ function ModelEditor({
|
||||
</select>
|
||||
</Field>
|
||||
<NumberField
|
||||
label="Max input tokens"
|
||||
label={t("registry.maxInputTokens")}
|
||||
value={runtime.max_input_tokens}
|
||||
nullable
|
||||
onChange={(value) => setRuntime({ max_input_tokens: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Min effective input"
|
||||
label={t("registry.minEffectiveInput")}
|
||||
value={runtime.min_effective_input_tokens}
|
||||
onChange={(value) =>
|
||||
setRuntime({ min_effective_input_tokens: value ?? 0 })
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="System reserve"
|
||||
label={t("registry.systemReserve")}
|
||||
value={runtime.fixed_system_reserve_tokens}
|
||||
onChange={(value) =>
|
||||
setRuntime({ fixed_system_reserve_tokens: value ?? 0 })
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Tools reserve"
|
||||
label={t("registry.toolsReserve")}
|
||||
value={runtime.fixed_tools_reserve_tokens}
|
||||
onChange={(value) =>
|
||||
setRuntime({ fixed_tools_reserve_tokens: value ?? 0 })
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Attachments reserve"
|
||||
label={t("registry.attachmentsReserve")}
|
||||
value={runtime.fixed_attachments_reserve_tokens}
|
||||
onChange={(value) =>
|
||||
setRuntime({ fixed_attachments_reserve_tokens: value ?? 0 })
|
||||
}
|
||||
/>
|
||||
<Field label="Limits status">
|
||||
<Field label={t("registry.limitsStatus")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runtime.limits_status}
|
||||
@@ -458,11 +480,15 @@ function ModelEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="needs_confirmation">needs confirmation</option>
|
||||
<option value="confirmed">confirmed</option>
|
||||
<option value="needs_confirmation">
|
||||
{t("registry.limitsStatusNeedsConfirmation")}
|
||||
</option>
|
||||
<option value="confirmed">
|
||||
{t("registry.limitsStatusConfirmed")}
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Limits source">
|
||||
<Field label={t("registry.limitsSource")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runtime.limits_source}
|
||||
@@ -476,13 +502,17 @@ function ModelEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="provider">provider</option>
|
||||
<option value="tested_contract">tested contract</option>
|
||||
<option value="user">{t("registry.limitsSourceUser")}</option>
|
||||
<option value="provider">
|
||||
{t("registry.limitsSourceProvider")}
|
||||
</option>
|
||||
<option value="tested_contract">
|
||||
{t("registry.limitsSourceTestedContract")}
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
<NumberField
|
||||
label="Top p"
|
||||
label={t("registry.topP")}
|
||||
value={runtime.top_p}
|
||||
nullable
|
||||
rule={rule("top_p")}
|
||||
@@ -491,8 +521,8 @@ function ModelEditor({
|
||||
<Field
|
||||
label={
|
||||
rule("reasoning_effort")?.supported === false
|
||||
? "Reasoning effort (unsupported)"
|
||||
: "Reasoning effort"
|
||||
? t("registry.reasoningEffortUnsupported")
|
||||
: t("registry.reasoningEffort")
|
||||
}
|
||||
>
|
||||
<select
|
||||
@@ -534,9 +564,11 @@ function ModelEditor({
|
||||
{outcome.ok ? (
|
||||
<>
|
||||
<p>
|
||||
Test passed in {outcome.result.latency_ms} ms — adapter spec
|
||||
revision {outcome.result.adapter_spec_revision}, state{" "}
|
||||
{outcome.result.model_status.state}.
|
||||
{t("registry.testPassedBanner", {
|
||||
latencyMs: outcome.result.latency_ms,
|
||||
revision: outcome.result.adapter_spec_revision,
|
||||
state: outcome.result.model_status.state,
|
||||
})}
|
||||
</p>
|
||||
<pre className="mt-1 overflow-x-auto font-mono text-[11px]">
|
||||
{JSON.stringify(outcome.result.effective_request_options, null, 2)}
|
||||
@@ -544,8 +576,10 @@ function ModelEditor({
|
||||
</>
|
||||
) : (
|
||||
<p role="alert">
|
||||
Test failed{outcome.code ? ` (${outcome.code})` : ""}:{" "}
|
||||
{outcome.message}
|
||||
{t("registry.testFailedBanner", {
|
||||
code: outcome.code ? ` (${outcome.code})` : "",
|
||||
message: outcome.message,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -579,6 +613,7 @@ function ProviderEditor({
|
||||
onCredentialStage: (credentialId: string, secret: string) => void;
|
||||
stagedCredential: string | undefined;
|
||||
}) {
|
||||
const t = useTranslations("modelConfig");
|
||||
const spec = adapterSpecFor(data.adapter_specs, provider.adapter);
|
||||
const authModes = supportedAuthModes(spec);
|
||||
const credentialStatus = credentialStatusFor(data, provider.auth.credential_id);
|
||||
@@ -603,14 +638,14 @@ function ProviderEditor({
|
||||
onChange({ ...provider, enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
enabled
|
||||
{t("registry.enabled")}
|
||||
</label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRemove}>
|
||||
Remove provider
|
||||
{t("registry.removeProvider")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label="Provider id">
|
||||
<Field label={t("registry.providerId")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -619,7 +654,7 @@ function ProviderEditor({
|
||||
onChange={(event) => onChange({ ...provider, id: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name">
|
||||
<Field label={t("registry.displayName")}>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
@@ -627,7 +662,7 @@ function ProviderEditor({
|
||||
onChange={(event) => onChange({ ...provider, name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Adapter">
|
||||
<Field label={t("registry.adapter")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={provider.adapter}
|
||||
@@ -662,8 +697,8 @@ function ProviderEditor({
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Any public HTTPS endpoint, or one of the configured development endpoints."
|
||||
label={t("registry.baseUrl")}
|
||||
hint={t("registry.baseUrlHint")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
@@ -684,7 +719,7 @@ function ProviderEditor({
|
||||
</datalist>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label="Auth mode">
|
||||
<Field label={t("registry.authMode")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={provider.auth.mode}
|
||||
@@ -713,13 +748,17 @@ function ProviderEditor({
|
||||
</Field>
|
||||
{provider.auth.mode !== "none" && (
|
||||
<Field
|
||||
label="API Key"
|
||||
label={t("registry.apiKey")}
|
||||
hint={
|
||||
stagedCredential
|
||||
? "staged — will be saved with the registry"
|
||||
? t("registry.apiKeyStaged")
|
||||
: credentialStatus?.configured
|
||||
? `configured${credentialStatus.hint ? ` (${credentialStatus.hint})` : ""} — leave blank to keep`
|
||||
: "paste your API key"
|
||||
? credentialStatus.hint
|
||||
? t("registry.apiKeyConfiguredWithHint", {
|
||||
hint: credentialStatus.hint,
|
||||
})
|
||||
: t("registry.apiKeyConfigured")
|
||||
: t("registry.apiKeyPaste")
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
@@ -730,7 +769,7 @@ function ProviderEditor({
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
credentialStatus?.configured
|
||||
? (credentialStatus.hint ?? "configured")
|
||||
? (credentialStatus.hint ?? t("registry.configuredPlaceholder"))
|
||||
: "sk-..."
|
||||
}
|
||||
onChange={(event) => {
|
||||
@@ -747,7 +786,11 @@ function ProviderEditor({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showSecret ? "Hide API key" : "Show API key"}
|
||||
aria-label={
|
||||
showSecret
|
||||
? t("registry.hideApiKey")
|
||||
: t("registry.showApiKey")
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowSecret((current) => !current)}
|
||||
>
|
||||
@@ -769,37 +812,37 @@ function ProviderEditor({
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
Advanced
|
||||
{t("registry.advanced")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<NumberField
|
||||
label="Timeout (s)"
|
||||
label={t("registry.timeoutSeconds")}
|
||||
value={provider.runtime.timeout_seconds}
|
||||
rule={spec?.parameters["timeout_seconds"] ?? null}
|
||||
onChange={(value) => setRuntime({ timeout_seconds: value ?? 120 })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Max retries"
|
||||
label={t("registry.maxRetries")}
|
||||
value={provider.runtime.max_retries}
|
||||
rule={spec?.parameters["max_retries"] ?? null}
|
||||
onChange={(value) => setRuntime({ max_retries: value ?? 0 })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Default temperature"
|
||||
label={t("registry.defaultTemperature")}
|
||||
value={provider.runtime.default_temperature}
|
||||
nullable
|
||||
rule={spec?.parameters["temperature"] ?? null}
|
||||
onChange={(value) => setRuntime({ default_temperature: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Default top p"
|
||||
label={t("registry.defaultTopP")}
|
||||
value={provider.runtime.default_top_p}
|
||||
nullable
|
||||
rule={spec?.parameters["top_p"] ?? null}
|
||||
onChange={(value) => setRuntime({ default_top_p: value })}
|
||||
/>
|
||||
<Field label="Default reasoning effort">
|
||||
<Field label={t("registry.defaultReasoningEffort")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={provider.runtime.default_reasoning_effort}
|
||||
@@ -823,7 +866,9 @@ function ProviderEditor({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Models</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("registry.modelsHeading")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -842,11 +887,11 @@ function ProviderEditor({
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add model
|
||||
{t("registry.addModel")}
|
||||
</Button>
|
||||
</div>
|
||||
{provider.models.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No models yet.</p>
|
||||
<p className="text-xs text-muted-foreground">{t("registry.noModels")}</p>
|
||||
)}
|
||||
{provider.models.map((model, modelIndex) => {
|
||||
const ref: ModelRef = { provider_id: provider.id, model_key: model.key };
|
||||
@@ -888,6 +933,7 @@ function ProviderEditor({
|
||||
* The backend remains the only validation authority; the editor renders
|
||||
* controls from `adapter_specs` and surfaces 422 detail paths verbatim. */
|
||||
export function RegistryEditor() {
|
||||
const t = useTranslations("modelConfig");
|
||||
const [data, setData] = useState<GetModelRegistryResponse | null>(null);
|
||||
const [draft, setDraft] = useState<RegistryV4 | null>(null);
|
||||
const [credentialWrites, setCredentialWrites] = useState<Map<string, string>>(
|
||||
@@ -919,7 +965,9 @@ export function RegistryEditor() {
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await readApiError(response);
|
||||
const error = await readApiError(response, (status) =>
|
||||
t("registry.requestFailed", { status })
|
||||
);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const body = (await response.json()) as GetModelRegistryResponse;
|
||||
@@ -930,10 +978,10 @@ export function RegistryEditor() {
|
||||
setSaveIssues(null);
|
||||
} catch (reason) {
|
||||
setLoadError(
|
||||
reason instanceof Error ? reason.message : "Failed to load the registry."
|
||||
reason instanceof Error ? reason.message : t("registry.loadFailed")
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -992,14 +1040,17 @@ export function RegistryEditor() {
|
||||
setSaveIssues(null);
|
||||
try {
|
||||
const writes = stagedWrites();
|
||||
const result = await putRegistry({
|
||||
expected_revision: data.revision,
|
||||
registry: draft,
|
||||
...(writes.length > 0 ? { credential_writes: writes } : {}),
|
||||
});
|
||||
const result = await putRegistry(
|
||||
{
|
||||
expected_revision: data.revision,
|
||||
registry: draft,
|
||||
...(writes.length > 0 ? { credential_writes: writes } : {}),
|
||||
},
|
||||
(status) => t("registry.requestFailed", { status })
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.status === 409) {
|
||||
toast.error("Registry changed elsewhere — reloaded the latest state.");
|
||||
toast.error(t("registry.changedElsewhere"));
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
@@ -1012,16 +1063,18 @@ export function RegistryEditor() {
|
||||
setCredentialWrites(new Map());
|
||||
setTestOutcomes({});
|
||||
invalidateAvailableModels();
|
||||
toast.success(`Registry saved (revision ${result.body.revision}).`);
|
||||
toast.success(
|
||||
t("registry.saved", { revision: result.body.revision })
|
||||
);
|
||||
} catch (reason) {
|
||||
errorToast(
|
||||
"models.registry",
|
||||
reason instanceof Error ? reason.message : "Failed to save the registry."
|
||||
reason instanceof Error ? reason.message : t("registry.saveFailed")
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [data, draft, stagedWrites, load]);
|
||||
}, [data, draft, stagedWrites, load, t]);
|
||||
|
||||
/** Save with unverifiable models disabled (design doc 9.2 rule 5): the
|
||||
* backend only stores an enabled model alongside a passing verification,
|
||||
@@ -1049,24 +1102,24 @@ export function RegistryEditor() {
|
||||
if (!replacement) {
|
||||
errorToast(
|
||||
"models.registry",
|
||||
"Editing the default primary model requires a second enabled model to take over."
|
||||
t("registry.primaryNeedsSecondModel")
|
||||
);
|
||||
return;
|
||||
}
|
||||
next.defaults.primary = replacement;
|
||||
}
|
||||
const writes = stagedWrites();
|
||||
const result = await putRegistry({
|
||||
expected_revision: data.revision,
|
||||
registry: next,
|
||||
...(writes.length > 0 ? { credential_writes: writes } : {}),
|
||||
});
|
||||
const result = await putRegistry(
|
||||
{
|
||||
expected_revision: data.revision,
|
||||
registry: next,
|
||||
...(writes.length > 0 ? { credential_writes: writes } : {}),
|
||||
},
|
||||
(status) => t("registry.requestFailed", { status })
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.status === 409) {
|
||||
errorToast(
|
||||
"models.registry",
|
||||
"Registry changed elsewhere — reloaded the latest state."
|
||||
);
|
||||
errorToast("models.registry", t("registry.changedElsewhere"));
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
@@ -1079,18 +1132,18 @@ export function RegistryEditor() {
|
||||
setCredentialWrites(new Map());
|
||||
invalidateAvailableModels();
|
||||
toast.success(
|
||||
`Registry saved (revision ${result.body.revision}); changed models are disabled — test and re-enable them when ready.`
|
||||
t("registry.savedDisabled", { revision: result.body.revision })
|
||||
);
|
||||
} catch (reason) {
|
||||
errorToast(
|
||||
"models.registry",
|
||||
reason instanceof Error ? reason.message : "Failed to save the registry."
|
||||
reason instanceof Error ? reason.message : t("registry.saveFailed")
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[data, draft, stagedWrites, load]
|
||||
[data, draft, stagedWrites, load, t]
|
||||
);
|
||||
|
||||
const testModel = useCallback(
|
||||
@@ -1108,7 +1161,9 @@ export function RegistryEditor() {
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await readApiError(response);
|
||||
const error = await readApiError(response, (status) =>
|
||||
t("registry.requestFailed", { status })
|
||||
);
|
||||
setTestOutcomes((current) => ({
|
||||
...current,
|
||||
[key]: { ok: false, message: error.message, code: error.code },
|
||||
@@ -1141,20 +1196,20 @@ export function RegistryEditor() {
|
||||
}
|
||||
: current
|
||||
);
|
||||
toast.success(`${key} passed the provider test.`);
|
||||
toast.success(t("registry.testPassedToast", { key }));
|
||||
invalidateAvailableModels();
|
||||
} else {
|
||||
setTestOutcomes((current) => ({
|
||||
...current,
|
||||
[key]: {
|
||||
ok: false,
|
||||
message: "The provider rejected the test request.",
|
||||
message: t("registry.providerRejectedTest"),
|
||||
code: null,
|
||||
},
|
||||
}));
|
||||
errorToast(
|
||||
"models.provider-test",
|
||||
`${key} failed the provider test.`
|
||||
t("registry.testFailedToast", { key })
|
||||
);
|
||||
}
|
||||
} catch (reason) {
|
||||
@@ -1163,7 +1218,9 @@ export function RegistryEditor() {
|
||||
[key]: {
|
||||
ok: false,
|
||||
message:
|
||||
reason instanceof Error ? reason.message : "Provider test failed.",
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("registry.testFailedGeneric"),
|
||||
code: null,
|
||||
},
|
||||
}));
|
||||
@@ -1171,13 +1228,13 @@ export function RegistryEditor() {
|
||||
setTesting((current) => ({ ...current, [key]: false }));
|
||||
}
|
||||
},
|
||||
[data, load]
|
||||
[data, load, t]
|
||||
);
|
||||
|
||||
if (forbidden) {
|
||||
return (
|
||||
<p className="rounded-md border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Model configuration requires an admin account.
|
||||
{t("registry.forbidden")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1186,13 +1243,15 @@ export function RegistryEditor() {
|
||||
<div className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm">
|
||||
<p role="alert">{loadError}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void load()}>
|
||||
Retry
|
||||
{t("registry.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data || !draft) {
|
||||
return <p className="text-sm text-muted-foreground">Loading registry…</p>;
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">{t("registry.loading")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultOptions = enabledModelRefs(draft);
|
||||
@@ -1213,20 +1272,20 @@ export function RegistryEditor() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded bg-accent px-1.5 py-0.5">
|
||||
state: {draft.state}
|
||||
{t("registry.stateBadge", { state: draft.state })}
|
||||
</span>
|
||||
<span className="rounded bg-accent px-1.5 py-0.5">
|
||||
revision: {data.revision}
|
||||
{t("registry.revisionBadge", { revision: data.revision })}
|
||||
</span>
|
||||
{dirty && (
|
||||
<span className="rounded bg-[var(--color-warning)]/10 px-1.5 py-0.5 text-[var(--color-warning)]">
|
||||
unsaved changes
|
||||
{t("registry.unsavedChanges")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 rounded-lg border border-border p-4 sm:grid-cols-2">
|
||||
<Field label="Default primary model">
|
||||
<Field label={t("registry.defaultPrimaryModel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={
|
||||
@@ -1242,7 +1301,7 @@ export function RegistryEditor() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
<option value="">{t("registry.noneOption")}</option>
|
||||
{defaultOptions.map((option) => (
|
||||
<option key={modelRefKey(option.ref)} value={modelRefKey(option.ref)}>
|
||||
{option.label}
|
||||
@@ -1254,10 +1313,14 @@ export function RegistryEditor() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[14rem_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1" role="listbox" aria-label="Providers">
|
||||
<div
|
||||
className="space-y-1"
|
||||
role="listbox"
|
||||
aria-label={t("registry.providers")}
|
||||
>
|
||||
{draft.providers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No providers yet. Add one from a template below.
|
||||
{t("registry.noProviders")}
|
||||
</p>
|
||||
)}
|
||||
{draft.providers.map((provider, index) => {
|
||||
@@ -1280,7 +1343,11 @@ export function RegistryEditor() {
|
||||
className={`inline-block size-2 shrink-0 rounded-full ${
|
||||
provider.enabled ? "bg-emerald-600" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
title={provider.enabled ? "provider enabled" : "provider disabled"}
|
||||
title={
|
||||
provider.enabled
|
||||
? t("registry.providerEnabled")
|
||||
: t("registry.providerDisabled")
|
||||
}
|
||||
/>
|
||||
<span className="truncate font-medium">
|
||||
{provider.name || provider.id}
|
||||
@@ -1307,14 +1374,14 @@ export function RegistryEditor() {
|
||||
className={inputClass}
|
||||
value={selectedTemplate}
|
||||
onChange={(event) => setSelectedTemplate(event.target.value)}
|
||||
aria-label="Provider template"
|
||||
aria-label={t("registry.providerTemplate")}
|
||||
>
|
||||
{PROVIDER_TEMPLATES.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="blank">Blank provider</option>
|
||||
<option value="blank">{t("registry.blankProvider")}</option>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1339,11 +1406,10 @@ export function RegistryEditor() {
|
||||
setSelectedProviderIndex(draft.providers.length);
|
||||
}}
|
||||
>
|
||||
Add provider
|
||||
{t("registry.addProvider")}
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Templates only prefill the adapter, Base URL, and suggested
|
||||
models; a saved provider is an ordinary profile.
|
||||
{t("registry.templatesFootnote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1351,7 +1417,7 @@ export function RegistryEditor() {
|
||||
<div>
|
||||
{selectedProvider === null ? (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Select a provider on the left, or add one from a template.
|
||||
{t("registry.selectProvider")}
|
||||
</p>
|
||||
) : (
|
||||
<ProviderEditor
|
||||
@@ -1406,7 +1472,9 @@ export function RegistryEditor() {
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{saveIssues.details.map((detail, index) => (
|
||||
<li key={index}>
|
||||
<span className="font-mono">{detail.path ?? "(request)"}</span>
|
||||
<span className="font-mono">
|
||||
{detail.path ?? t("registry.requestPath")}
|
||||
</span>
|
||||
{detail.code ? ` — ${detail.code}` : ""}
|
||||
</li>
|
||||
))}
|
||||
@@ -1417,19 +1485,18 @@ export function RegistryEditor() {
|
||||
{reverifyRefs.length > 0 && (
|
||||
<div className="rounded-md border border-[var(--color-warning)]/30 bg-[var(--color-warning)]/5 px-4 py-3 text-xs">
|
||||
<p className="font-medium">
|
||||
Changed models will be saved as disabled:{" "}
|
||||
{reverifyRefs.map(modelRefKey).join(", ")}
|
||||
{t("registry.changedModelsDisabled", {
|
||||
keys: reverifyRefs.map(modelRefKey).join(", "),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
An enabled model needs a passing provider test at its current
|
||||
configuration. Saving disables the changed models
|
||||
{repointTarget
|
||||
? ` and points the default primary at ${modelRefKey(
|
||||
repointTarget
|
||||
)}`
|
||||
: ""}
|
||||
; use each model's Test button when ready, then re-enable and
|
||||
save again.
|
||||
{t("registry.changedModelsExplanation", {
|
||||
repoint: repointTarget
|
||||
? t("registry.repointPrimary", {
|
||||
target: modelRefKey(repointTarget),
|
||||
})
|
||||
: "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1445,10 +1512,10 @@ export function RegistryEditor() {
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
{saving
|
||||
? "Saving…"
|
||||
? t("registry.saving")
|
||||
: reverifyRefs.length > 0
|
||||
? "Save (changed models disabled)"
|
||||
: "Save registry"}
|
||||
? t("registry.saveWithDisabled")
|
||||
: t("registry.saveRegistry")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1460,14 +1527,14 @@ export function RegistryEditor() {
|
||||
setSaveIssues(null);
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
{t("registry.discardChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
IDs use lowercase letters, digits, dot, underscore and dash
|
||||
(e.g. <span className="font-mono">zhipu-glm/glm-5.2</span>):{" "}
|
||||
{ID_PATTERN.source}. Enabling a model requires confirmed limits and a
|
||||
passing provider test at the current configuration.
|
||||
{t.rich("registry.idFootnote", {
|
||||
pattern: ID_PATTERN.source,
|
||||
mono: (chunks) => <span className="font-mono">{chunks}</span>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -15,12 +16,6 @@ interface BackupEntry {
|
||||
origin: "manual" | "auto";
|
||||
}
|
||||
|
||||
const fetcher = async (url: string): Promise<{ backups: BackupEntry[] }> => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Backup request failed (${response.status}).`);
|
||||
return (await response.json()) as { backups: BackupEntry[] };
|
||||
};
|
||||
|
||||
function formatSize(size: number): string {
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
@@ -28,6 +23,16 @@ function formatSize(size: number): string {
|
||||
}
|
||||
|
||||
export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
const t = useTranslations("systemConfig");
|
||||
|
||||
const fetcher = async (url: string): Promise<{ backups: BackupEntry[] }> => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(t("backup.requestFailed", { status: response.status }));
|
||||
}
|
||||
return (await response.json()) as { backups: BackupEntry[] };
|
||||
};
|
||||
|
||||
const { data, mutate } = useSWR("/api/system/backup", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -40,22 +45,28 @@ export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { message?: string }
|
||||
| null;
|
||||
if (!response.ok) throw new Error(body?.message || "Backup failed.");
|
||||
if (!response.ok) throw new Error(body?.message || t("backup.backupFailed"));
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
errorToast("backup.run", error instanceof Error ? error.message : "Backup failed.");
|
||||
errorToast(
|
||||
"backup.run",
|
||||
error instanceof Error ? error.message : t("backup.backupFailed")
|
||||
);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (name: string) => {
|
||||
if (!window.confirm(`Delete ${name}?`)) return;
|
||||
if (!window.confirm(t("backup.confirmDelete", { name }))) return;
|
||||
const response = await fetch(`/api/system/backup/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
errorToast("backup.delete", `Delete failed (${response.status}).`);
|
||||
errorToast(
|
||||
"backup.delete",
|
||||
t("backup.deleteFailed", { status: response.status })
|
||||
);
|
||||
return;
|
||||
}
|
||||
void mutate();
|
||||
@@ -70,11 +81,11 @@ export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="backend-dir">Backend data directory</Label>
|
||||
<Label htmlFor="backend-dir">{t("backup.backendDir")}</Label>
|
||||
<Input
|
||||
id="backend-dir"
|
||||
value={draft.backup.backendDataDir}
|
||||
placeholder="e.g. /srv/evoscientist/data (optional)"
|
||||
placeholder={t("backup.backendDirPlaceholder")}
|
||||
onChange={(event) =>
|
||||
setDraft((config) => ({
|
||||
...config,
|
||||
@@ -91,10 +102,10 @@ export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
checked={draft.backup.schedule.enabled}
|
||||
onChange={(event) => setSchedule({ enabled: event.target.checked })}
|
||||
/>
|
||||
Scheduled backups
|
||||
{t("backup.scheduled")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
every
|
||||
{t("backup.every")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -108,10 +119,10 @@ export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
hours
|
||||
{t("backup.hours")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
keep
|
||||
{t("backup.keep")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -128,15 +139,15 @@ export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Backup history</Label>
|
||||
<Label>{t("backup.history")}</Label>
|
||||
<Button size="sm" onClick={() => void runNow()} disabled={running}>
|
||||
{running ? "Backing up…" : "Back up now"}
|
||||
{running ? t("backup.running") : t("backup.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto rounded-md border border-border">
|
||||
{!data || data.backups.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No backups yet.
|
||||
{t("backup.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
@@ -151,14 +162,14 @@ export function BackupTab({ draft, setDraft }: TabProps) {
|
||||
href={`/api/system/backup/${encodeURIComponent(entry.name)}`}
|
||||
className="text-[var(--brand)] hover:underline"
|
||||
>
|
||||
Download
|
||||
{t("backup.download")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-destructive hover:underline"
|
||||
onClick={() => void remove(entry.name)}
|
||||
>
|
||||
Delete
|
||||
{t("backup.delete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { mutate as globalMutate } from "swr";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -27,6 +28,7 @@ function BrandingUpload({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const t = useTranslations("systemConfig");
|
||||
const fileField = kind === "logo" ? "logoFile" : "faviconFile";
|
||||
|
||||
const call = async (url: string, init: RequestInit, fallback: string) => {
|
||||
@@ -60,14 +62,18 @@ function BrandingUpload({
|
||||
const form = new FormData();
|
||||
form.set("kind", kind);
|
||||
form.set("file", file);
|
||||
void call("/api/system/branding", { method: "POST", body: form }, "Upload failed.");
|
||||
void call(
|
||||
"/api/system/branding",
|
||||
{ method: "POST", body: form },
|
||||
t("general.uploadFailed")
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={preview ?? currentSrc}
|
||||
alt={`${label} preview`}
|
||||
alt={t("general.previewAlt", { label })}
|
||||
className={kind === "logo" ? "size-8 shrink-0" : "size-5 shrink-0"}
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
@@ -89,17 +95,21 @@ function BrandingUpload({
|
||||
disabled={busy || !preview}
|
||||
onClick={upload}
|
||||
>
|
||||
Upload
|
||||
{t("general.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void call(`/api/system/branding?kind=${kind}`, { method: "DELETE" }, "Restore failed.")
|
||||
void call(
|
||||
`/api/system/branding?kind=${kind}`,
|
||||
{ method: "DELETE" },
|
||||
t("general.restoreFailed")
|
||||
)
|
||||
}
|
||||
>
|
||||
Restore default
|
||||
{t("general.restoreDefault")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -107,10 +117,11 @@ function BrandingUpload({
|
||||
|
||||
export function GeneralTab({ draft, setDraft }: TabProps) {
|
||||
const publicConfig = usePublicSystemConfig();
|
||||
const t = useTranslations("systemConfig");
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wordmark">Wordmark (top-left & login page)</Label>
|
||||
<Label htmlFor="wordmark">{t("general.wordmark")}</Label>
|
||||
<Input
|
||||
id="wordmark"
|
||||
value={draft.branding.wordmark}
|
||||
@@ -123,10 +134,10 @@ export function GeneralTab({ draft, setDraft }: TabProps) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<BrandingUpload kind="logo" label="Logo" currentSrc={logoSrc(publicConfig)} setDraft={setDraft} />
|
||||
<BrandingUpload kind="logo" label={t("general.logo")} currentSrc={logoSrc(publicConfig)} setDraft={setDraft} />
|
||||
<BrandingUpload
|
||||
kind="favicon"
|
||||
label="Favicon"
|
||||
label={t("general.favicon")}
|
||||
currentSrc={
|
||||
publicConfig && publicConfig.faviconVersion > 0
|
||||
? `/api/system/branding/asset/favicon?v=${publicConfig.faviconVersion}`
|
||||
@@ -134,10 +145,7 @@ export function GeneralTab({ draft, setDraft }: TabProps) {
|
||||
}
|
||||
setDraft={setDraft}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPEG or SVG, up to 512KB. The favicon also accepts ICO. Changes
|
||||
apply on save for the wordmark; uploads apply immediately.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("general.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type { TabProps } from "./SystemConfigDialog";
|
||||
|
||||
export function LoginTermsTab({ draft, setDraft }: TabProps) {
|
||||
const t = useTranslations("systemConfig");
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -19,16 +21,16 @@ export function LoginTermsTab({ draft, setDraft }: TabProps) {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Show terms text on the login page (display only)
|
||||
{t("loginTerms.showTerms")}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-terms">Terms text (Markdown)</Label>
|
||||
<Label htmlFor="login-terms">{t("loginTerms.termsLabel")}</Label>
|
||||
<textarea
|
||||
id="login-terms"
|
||||
value={draft.loginTerms.markdown}
|
||||
rows={6}
|
||||
maxLength={20000}
|
||||
placeholder="e.g. By signing in you agree to the acceptable use policy…"
|
||||
placeholder={t("loginTerms.placeholder")}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 focus-visible:border-[var(--brand)]"
|
||||
onChange={(event) =>
|
||||
setDraft((config) => ({
|
||||
@@ -40,7 +42,7 @@ export function LoginTermsTab({ draft, setDraft }: TabProps) {
|
||||
</div>
|
||||
{draft.loginTerms.enabled && draft.loginTerms.markdown.trim() && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Preview</Label>
|
||||
<Label>{t("loginTerms.preview")}</Label>
|
||||
<div className="max-h-48 overflow-y-auto rounded-md border border-border bg-muted/40 p-3 text-sm">
|
||||
<MarkdownContent content={draft.loginTerms.markdown} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { TabProps } from "./SystemConfigDialog";
|
||||
|
||||
@@ -16,6 +17,7 @@ function TriState({
|
||||
confirmOff?: string;
|
||||
confirmOn?: string;
|
||||
}) {
|
||||
const t = useTranslations("systemConfig");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
@@ -30,9 +32,9 @@ function TriState({
|
||||
onChange(next);
|
||||
}}
|
||||
>
|
||||
<option value="default">Default (env)</option>
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="default">{t("security.options.default")}</option>
|
||||
<option value="on">{t("security.options.on")}</option>
|
||||
<option value="off">{t("security.options.off")}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
@@ -79,6 +81,7 @@ function NullableNumber({
|
||||
}
|
||||
|
||||
export function SecurityTab({ draft, setDraft }: TabProps) {
|
||||
const t = useTranslations("systemConfig");
|
||||
const set = (patch: Partial<TabProps["draft"]["security"]>) =>
|
||||
setDraft((config) => ({
|
||||
...config,
|
||||
@@ -87,14 +90,14 @@ export function SecurityTab({ draft, setDraft }: TabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TriState
|
||||
label="WebUI authentication"
|
||||
label={t("security.authEnabled")}
|
||||
value={draft.security.authEnabled}
|
||||
confirmOff="Disabling authentication opens the UI to anyone who can reach it. Continue?"
|
||||
confirmOn="Enabling authentication requires WEBUI_AUTH_SECRET to be set in the server environment. Without it, every page (including this dialog) will return 503 and recovery requires hand-editing ~/.evoscientist/system-config.json. Continue?"
|
||||
confirmOff={t("security.confirmAuthOff")}
|
||||
confirmOn={t("security.confirmAuthOn")}
|
||||
onChange={(authEnabled) => set({ authEnabled })}
|
||||
/>
|
||||
<NullableNumber
|
||||
label="Session lifetime (hours)"
|
||||
label={t("security.sessionTtl")}
|
||||
value={draft.security.sessionTtlHours}
|
||||
min={1}
|
||||
max={720}
|
||||
@@ -102,7 +105,7 @@ export function SecurityTab({ draft, setDraft }: TabProps) {
|
||||
onChange={(sessionTtlHours) => set({ sessionTtlHours })}
|
||||
/>
|
||||
<NullableNumber
|
||||
label="Password minimum length"
|
||||
label={t("security.passwordMinLength")}
|
||||
value={draft.security.passwordMinLength}
|
||||
min={6}
|
||||
max={64}
|
||||
@@ -110,22 +113,19 @@ export function SecurityTab({ draft, setDraft }: TabProps) {
|
||||
onChange={(passwordMinLength) => set({ passwordMinLength })}
|
||||
/>
|
||||
<TriState
|
||||
label="Login captcha"
|
||||
label={t("security.captchaEnabled")}
|
||||
value={draft.security.captchaEnabled}
|
||||
onChange={(captchaEnabled) => set({ captchaEnabled })}
|
||||
/>
|
||||
<NullableNumber
|
||||
label="Captcha after N failed logins"
|
||||
label={t("security.captchaThreshold")}
|
||||
value={draft.security.captchaThreshold}
|
||||
min={1}
|
||||
max={10}
|
||||
hint="3"
|
||||
onChange={(captchaThreshold) => set({ captchaThreshold })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Empty fields keep the environment defaults. Session lifetime applies to
|
||||
newly issued sessions; existing sessions keep their fixed expiry.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("security.footnote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { mutate as globalMutate } from "swr";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -42,19 +43,8 @@ export interface TabProps {
|
||||
|
||||
const CONFIG_KEY = "/api/system/config";
|
||||
|
||||
const fetcher = async (url: string): Promise<SystemConfig> => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Config request failed (${response.status}).`);
|
||||
return (await response.json()) as SystemConfig;
|
||||
};
|
||||
|
||||
type TabId = "general" | "terms" | "security" | "backup";
|
||||
const TABS: Array<{ id: TabId; label: string }> = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "terms", label: "Login terms" },
|
||||
{ id: "security", label: "Security" },
|
||||
{ id: "backup", label: "Data backup" },
|
||||
];
|
||||
const TABS: TabId[] = ["general", "terms", "security", "backup"];
|
||||
|
||||
export function SystemConfigDialog({
|
||||
open,
|
||||
@@ -63,6 +53,16 @@ export function SystemConfigDialog({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("systemConfig");
|
||||
|
||||
const fetcher = async (url: string): Promise<SystemConfig> => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(t("requestFailed", { status: response.status }));
|
||||
}
|
||||
return (await response.json()) as SystemConfig;
|
||||
};
|
||||
|
||||
const { data, mutate } = useSWR<SystemConfig>(open ? CONFIG_KEY : null, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export function SystemConfigDialog({
|
||||
const dirty = Boolean(draft && data && JSON.stringify(draft) !== JSON.stringify(data));
|
||||
|
||||
const close = (next: boolean) => {
|
||||
if (!next && dirty && !window.confirm("Discard unsaved changes?")) return;
|
||||
if (!next && dirty && !window.confirm(t("discardConfirm"))) return;
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
@@ -102,11 +102,16 @@ export function SystemConfigDialog({
|
||||
| { message?: string }
|
||||
| null;
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message || `Save failed (${response.status}).`);
|
||||
throw new Error(
|
||||
body?.message || t("saveFailedStatus", { status: response.status })
|
||||
);
|
||||
}
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
errorToast("system-config.save", error instanceof Error ? error.message : "Save failed.");
|
||||
errorToast(
|
||||
"system-config.save",
|
||||
error instanceof Error ? error.message : t("saveFailed")
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -116,14 +121,11 @@ export function SystemConfigDialog({
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>System config</DialogTitle>
|
||||
<DialogDescription>
|
||||
System-wide parameters. Admin only; unset fields fall back to
|
||||
environment defaults.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
{TABS.map(({ id, label }) => (
|
||||
{TABS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -134,13 +136,15 @@ export function SystemConfigDialog({
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{t(`tabs.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-[280px] py-4">
|
||||
{!draft ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">Loading…</p>
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("loading")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{tab === "general" && (
|
||||
@@ -160,10 +164,10 @@ export function SystemConfigDialog({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => close(false)}>
|
||||
Close
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={!draft || !dirty || saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
changePassword: {
|
||||
title: "Change Password",
|
||||
description:
|
||||
"Changing the password signs out every existing session except this one.",
|
||||
currentPassword: "Current password",
|
||||
newPassword: "New password",
|
||||
confirmPassword: "Confirm new password",
|
||||
failed: "Unable to change the password.",
|
||||
cancel: "Cancel",
|
||||
submit: "Save Password",
|
||||
saving: "Saving...",
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,16 @@
|
||||
export default {
|
||||
dialogs: {},
|
||||
dialogs: {
|
||||
account: {
|
||||
title: "User information",
|
||||
description: "Your signed-in account for this workspace.",
|
||||
detailsLabel: "Account details",
|
||||
username: "Username",
|
||||
loading: "Loading account...",
|
||||
unavailable: "Account information is unavailable.",
|
||||
roles: {
|
||||
admin: "Admin",
|
||||
user: "User",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ export default {
|
||||
header: {
|
||||
signOut: "Sign out",
|
||||
configuration: "Configuration",
|
||||
account: "Account",
|
||||
userInformation: "User information",
|
||||
changePassword: "Change password",
|
||||
modelConfiguration: "Model configuration",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export default {
|
||||
imageModels: {
|
||||
title: "Image models",
|
||||
description:
|
||||
"Manage dedicated image-generation models (stored in config.yaml). Admin only.",
|
||||
close: "Close",
|
||||
loading: "Loading image models…",
|
||||
loadFailed: "Failed to load image models.",
|
||||
retry: "Retry",
|
||||
forbidden: "Image model configuration requires an admin account.",
|
||||
defaultModel: "Default model",
|
||||
none: "— none —",
|
||||
timeoutSeconds: "Timeout (s)",
|
||||
modelsHeading: "Models",
|
||||
addModel: "Add model",
|
||||
empty: "No models yet.",
|
||||
save: "Save",
|
||||
saving: "Saving…",
|
||||
discardChanges: "Discard changes",
|
||||
unsavedChanges: "unsaved changes",
|
||||
saved: "Image models saved.",
|
||||
saveFailed: "Failed to save image models.",
|
||||
fixParamsBeforeSaving: "Fix the invalid params JSON before saving.",
|
||||
apiKeyWriteOnlyNote:
|
||||
"API keys are write-only: leave the field blank to keep the stored key.",
|
||||
card: {
|
||||
newModel: "(new model)",
|
||||
unnamed: "Image model",
|
||||
enabled: "enabled",
|
||||
test: "Test",
|
||||
testing: "Testing…",
|
||||
remove: "Remove",
|
||||
fixParamsBeforeTesting: "Fix the invalid params JSON before testing.",
|
||||
testPassed: "{id} passed the test in {seconds} s.",
|
||||
testPassedBanner:
|
||||
"Test passed in {seconds} s — the provider completed a real generation with these settings.",
|
||||
testFailedToast: "{id} failed the test: {message}",
|
||||
testFailedGeneric: "Test failed.",
|
||||
testFailedBanner: "Test failed: {message}",
|
||||
modelId: "Model id",
|
||||
displayName: "Display name",
|
||||
provider: "Provider",
|
||||
baseUrl: "Base URL",
|
||||
defaultSize: "Default size",
|
||||
defaultQuality: "Default quality",
|
||||
apiKey: "API Key",
|
||||
apiKeyConfiguredNoHint: "configured — leave blank to keep",
|
||||
apiKeyConfiguredWithHint: "configured ({hint}) — leave blank to keep",
|
||||
apiKeyNotConfigured: "paste your API key",
|
||||
showApiKey: "Show API key",
|
||||
hideApiKey: "Hide API key",
|
||||
paramsLabel: "Params (JSON object, optional)",
|
||||
paramsErrorPrefix: "Params: {message}",
|
||||
paramsErrors: {
|
||||
notObject: "params must be a JSON object",
|
||||
invalidJson: "invalid JSON",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,10 @@ import panels from "./panels";
|
||||
import login from "./login";
|
||||
import errors from "./errors";
|
||||
import userManagement from "./userManagement";
|
||||
import changePassword from "./changePassword";
|
||||
import modelConfig from "./modelConfig";
|
||||
import imageModels from "./imageModels";
|
||||
import systemConfig from "./systemConfig";
|
||||
|
||||
export default {
|
||||
...common,
|
||||
@@ -18,4 +22,8 @@ export default {
|
||||
...login,
|
||||
...errors,
|
||||
...userManagement,
|
||||
...changePassword,
|
||||
...modelConfig,
|
||||
...imageModels,
|
||||
...systemConfig,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
export default {
|
||||
modelConfig: {
|
||||
title: "Model registry",
|
||||
description:
|
||||
"Manage providers, models, credentials and defaults. Admin only; every save is validated by the backend.",
|
||||
collapseAgentActions: "Collapse agent actions by default",
|
||||
close: "Close",
|
||||
registry: {
|
||||
requestFailed: "Request failed ({status}).",
|
||||
minHint: "min {value}",
|
||||
maxHint: "max {value}",
|
||||
inheritPlaceholder: "inherit",
|
||||
statusNew: "new",
|
||||
toggleParameters: "Toggle parameters for {key}",
|
||||
enabled: "enabled",
|
||||
saveBeforeTesting:
|
||||
"Save the registry before testing (tests only run against saved models).",
|
||||
testing: "Testing…",
|
||||
test: "Test",
|
||||
remove: "Remove",
|
||||
modelKey: "Model key",
|
||||
displayName: "Display name",
|
||||
upstreamModelId: "Upstream model id",
|
||||
contextWindow: "Context window",
|
||||
maxOutputTokens: "Max output tokens",
|
||||
temperature: "Temperature",
|
||||
temperatureUnsupported: "Temperature (unsupported)",
|
||||
capabilities: "Capabilities",
|
||||
capabilityTools: "tools",
|
||||
capabilityVision: "vision",
|
||||
capabilityStructuredOutput: "structured output",
|
||||
advanced: "Advanced",
|
||||
limitMode: "Limit mode",
|
||||
maxInputTokens: "Max input tokens",
|
||||
minEffectiveInput: "Min effective input",
|
||||
systemReserve: "System reserve",
|
||||
toolsReserve: "Tools reserve",
|
||||
attachmentsReserve: "Attachments reserve",
|
||||
limitsStatus: "Limits status",
|
||||
limitsStatusNeedsConfirmation: "needs confirmation",
|
||||
limitsStatusConfirmed: "confirmed",
|
||||
limitsSource: "Limits source",
|
||||
limitsSourceUser: "user",
|
||||
limitsSourceProvider: "provider",
|
||||
limitsSourceTestedContract: "tested contract",
|
||||
topP: "Top p",
|
||||
reasoningEffort: "Reasoning effort",
|
||||
reasoningEffortUnsupported: "Reasoning effort (unsupported)",
|
||||
testPassedBanner:
|
||||
"Test passed in {latencyMs} ms — adapter spec revision {revision}, state {state}.",
|
||||
testFailedBanner: "Test failed{code}: {message}",
|
||||
removeProvider: "Remove provider",
|
||||
providerId: "Provider id",
|
||||
adapter: "Adapter",
|
||||
baseUrl: "Base URL",
|
||||
baseUrlHint:
|
||||
"Any public HTTPS endpoint, or one of the configured development endpoints.",
|
||||
authMode: "Auth mode",
|
||||
apiKey: "API Key",
|
||||
apiKeyStaged: "staged — will be saved with the registry",
|
||||
apiKeyConfiguredWithHint: "configured ({hint}) — leave blank to keep",
|
||||
apiKeyConfigured: "configured — leave blank to keep",
|
||||
apiKeyPaste: "paste your API key",
|
||||
configuredPlaceholder: "configured",
|
||||
showApiKey: "Show API key",
|
||||
hideApiKey: "Hide API key",
|
||||
timeoutSeconds: "Timeout (s)",
|
||||
maxRetries: "Max retries",
|
||||
defaultTemperature: "Default temperature",
|
||||
defaultTopP: "Default top p",
|
||||
defaultReasoningEffort: "Default reasoning effort",
|
||||
modelsHeading: "Models",
|
||||
addModel: "Add model",
|
||||
noModels: "No models yet.",
|
||||
loadFailed: "Failed to load the registry.",
|
||||
changedElsewhere: "Registry changed elsewhere — reloaded the latest state.",
|
||||
saved: "Registry saved (revision {revision}).",
|
||||
saveFailed: "Failed to save the registry.",
|
||||
primaryNeedsSecondModel:
|
||||
"Editing the default primary model requires a second enabled model to take over.",
|
||||
savedDisabled:
|
||||
"Registry saved (revision {revision}); changed models are disabled — test and re-enable them when ready.",
|
||||
providerRejectedTest: "The provider rejected the test request.",
|
||||
testPassedToast: "{key} passed the provider test.",
|
||||
testFailedToast: "{key} failed the provider test.",
|
||||
testFailedGeneric: "Provider test failed.",
|
||||
forbidden: "Model configuration requires an admin account.",
|
||||
retry: "Retry",
|
||||
loading: "Loading registry…",
|
||||
stateBadge: "state: {state}",
|
||||
revisionBadge: "revision: {revision}",
|
||||
unsavedChanges: "unsaved changes",
|
||||
defaultPrimaryModel: "Default primary model",
|
||||
noneOption: "— none —",
|
||||
providers: "Providers",
|
||||
noProviders: "No providers yet. Add one from a template below.",
|
||||
providerEnabled: "provider enabled",
|
||||
providerDisabled: "provider disabled",
|
||||
providerTemplate: "Provider template",
|
||||
blankProvider: "Blank provider",
|
||||
addProvider: "Add provider",
|
||||
templatesFootnote:
|
||||
"Templates only prefill the adapter, Base URL, and suggested models; a saved provider is an ordinary profile.",
|
||||
selectProvider: "Select a provider on the left, or add one from a template.",
|
||||
requestPath: "(request)",
|
||||
changedModelsDisabled: "Changed models will be saved as disabled: {keys}",
|
||||
changedModelsExplanation:
|
||||
"An enabled model needs a passing provider test at its current configuration. Saving disables the changed models{repoint}; use each model's Test button when ready, then re-enable and save again.",
|
||||
repointPrimary: " and points the default primary at {target}",
|
||||
saving: "Saving…",
|
||||
saveWithDisabled: "Save (changed models disabled)",
|
||||
saveRegistry: "Save registry",
|
||||
discardChanges: "Discard changes",
|
||||
idFootnote:
|
||||
"IDs use lowercase letters, digits, dot, underscore and dash (e.g. <mono>zhipu-glm/glm-5.2</mono>): {pattern}. Enabling a model requires confirmed limits and a passing provider test at the current configuration.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
export default {
|
||||
systemConfig: {
|
||||
title: "System config",
|
||||
description:
|
||||
"System-wide parameters. Admin only; unset fields fall back to environment defaults.",
|
||||
loading: "Loading…",
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
saving: "Saving…",
|
||||
discardConfirm: "Discard unsaved changes?",
|
||||
saveFailed: "Save failed.",
|
||||
saveFailedStatus: "Save failed ({status}).",
|
||||
requestFailed: "Config request failed ({status}).",
|
||||
tabs: {
|
||||
general: "General",
|
||||
terms: "Login terms",
|
||||
security: "Security",
|
||||
backup: "Data backup",
|
||||
},
|
||||
general: {
|
||||
wordmark: "Wordmark (top-left & login page)",
|
||||
logo: "Logo",
|
||||
favicon: "Favicon",
|
||||
hint: "PNG, JPEG or SVG, up to 512KB. The favicon also accepts ICO. Changes apply on save for the wordmark; uploads apply immediately.",
|
||||
upload: "Upload",
|
||||
restoreDefault: "Restore default",
|
||||
uploadFailed: "Upload failed.",
|
||||
restoreFailed: "Restore failed.",
|
||||
previewAlt: "{label} preview",
|
||||
},
|
||||
security: {
|
||||
authEnabled: "WebUI authentication",
|
||||
sessionTtl: "Session lifetime (hours)",
|
||||
passwordMinLength: "Password minimum length",
|
||||
captchaEnabled: "Login captcha",
|
||||
captchaThreshold: "Captcha after N failed logins",
|
||||
options: {
|
||||
default: "Default (env)",
|
||||
on: "On",
|
||||
off: "Off",
|
||||
},
|
||||
confirmAuthOff:
|
||||
"Disabling authentication opens the UI to anyone who can reach it. Continue?",
|
||||
confirmAuthOn:
|
||||
"Enabling authentication requires WEBUI_AUTH_SECRET to be set in the server environment. Without it, every page (including this dialog) will return 503 and recovery requires hand-editing ~/.evoscientist/system-config.json. Continue?",
|
||||
footnote:
|
||||
"Empty fields keep the environment defaults. Session lifetime applies to newly issued sessions; existing sessions keep their fixed expiry.",
|
||||
},
|
||||
loginTerms: {
|
||||
showTerms: "Show terms text on the login page (display only)",
|
||||
termsLabel: "Terms text (Markdown)",
|
||||
placeholder:
|
||||
"e.g. By signing in you agree to the acceptable use policy…",
|
||||
preview: "Preview",
|
||||
},
|
||||
backup: {
|
||||
backendDir: "Backend data directory",
|
||||
backendDirPlaceholder: "e.g. /srv/evoscientist/data (optional)",
|
||||
scheduled: "Scheduled backups",
|
||||
every: "every",
|
||||
hours: "hours",
|
||||
keep: "keep",
|
||||
history: "Backup history",
|
||||
runNow: "Back up now",
|
||||
running: "Backing up…",
|
||||
empty: "No backups yet.",
|
||||
download: "Download",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete {name}?",
|
||||
backupFailed: "Backup failed.",
|
||||
deleteFailed: "Delete failed ({status}).",
|
||||
requestFailed: "Backup request failed ({status}).",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,10 @@ import enPanels from "./en/panels";
|
||||
import enLogin from "./en/login";
|
||||
import enErrors from "./en/errors";
|
||||
import enUserManagement from "./en/userManagement";
|
||||
import enChangePassword from "./en/changePassword";
|
||||
import enModelConfig from "./en/modelConfig";
|
||||
import enImageModels from "./en/imageModels";
|
||||
import enSystemConfig from "./en/systemConfig";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
@@ -61,6 +65,10 @@ describe("message catalogs", () => {
|
||||
enLogin,
|
||||
enErrors,
|
||||
enUserManagement,
|
||||
enChangePassword,
|
||||
enModelConfig,
|
||||
enImageModels,
|
||||
enSystemConfig,
|
||||
];
|
||||
const total = modules.reduce((n, m) => n + Object.keys(m).length, 0);
|
||||
expect(Object.keys(en).length).toBe(total);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
changePassword: {
|
||||
title: "修改密码",
|
||||
description: "修改密码后,除当前会话外的所有现有会话都将退出登录。",
|
||||
currentPassword: "当前密码",
|
||||
newPassword: "新密码",
|
||||
confirmPassword: "确认新密码",
|
||||
failed: "无法修改密码。",
|
||||
cancel: "取消",
|
||||
submit: "保存密码",
|
||||
saving: "保存中...",
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,16 @@
|
||||
export default {
|
||||
dialogs: {},
|
||||
dialogs: {
|
||||
account: {
|
||||
title: "用户信息",
|
||||
description: "您在当前工作区登录的账号。",
|
||||
detailsLabel: "账号详情",
|
||||
username: "用户名",
|
||||
loading: "正在加载账号...",
|
||||
unavailable: "无法获取账号信息。",
|
||||
roles: {
|
||||
admin: "管理员",
|
||||
user: "普通用户",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ export default {
|
||||
header: {
|
||||
signOut: "退出登录",
|
||||
configuration: "配置",
|
||||
account: "账号",
|
||||
userInformation: "用户信息",
|
||||
changePassword: "修改密码",
|
||||
modelConfiguration: "模型配置",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export default {
|
||||
imageModels: {
|
||||
title: "图像模型",
|
||||
description: "管理专用图像生成模型(存储在 config.yaml 中)。仅限管理员。",
|
||||
close: "关闭",
|
||||
loading: "正在加载图像模型…",
|
||||
loadFailed: "加载图像模型失败。",
|
||||
retry: "重试",
|
||||
forbidden: "图像模型配置需要管理员账号。",
|
||||
defaultModel: "默认模型",
|
||||
none: "— 无 —",
|
||||
timeoutSeconds: "超时时间(秒)",
|
||||
modelsHeading: "模型",
|
||||
addModel: "添加模型",
|
||||
empty: "暂无模型。",
|
||||
save: "保存",
|
||||
saving: "保存中…",
|
||||
discardChanges: "放弃更改",
|
||||
unsavedChanges: "未保存的更改",
|
||||
saved: "图像模型已保存。",
|
||||
saveFailed: "保存图像模型失败。",
|
||||
fixParamsBeforeSaving: "请先修正无效的 params JSON 再保存。",
|
||||
apiKeyWriteOnlyNote: "API 密钥为只写:留空则保留已存储的密钥。",
|
||||
card: {
|
||||
newModel: "(新模型)",
|
||||
unnamed: "图像模型",
|
||||
enabled: "启用",
|
||||
test: "测试",
|
||||
testing: "测试中…",
|
||||
remove: "移除",
|
||||
fixParamsBeforeTesting: "请先修正无效的 params JSON 再测试。",
|
||||
testPassed: "{id} 测试通过,耗时 {seconds} 秒。",
|
||||
testPassedBanner:
|
||||
"测试通过,耗时 {seconds} 秒——提供商已使用这些设置完成了一次真实生成。",
|
||||
testFailedToast: "{id} 测试失败:{message}",
|
||||
testFailedGeneric: "测试失败。",
|
||||
testFailedBanner: "测试失败:{message}",
|
||||
modelId: "模型 ID",
|
||||
displayName: "显示名称",
|
||||
provider: "提供商",
|
||||
baseUrl: "基础 URL",
|
||||
defaultSize: "默认尺寸",
|
||||
defaultQuality: "默认质量",
|
||||
apiKey: "API 密钥",
|
||||
apiKeyConfiguredNoHint: "已配置 — 留空则保留",
|
||||
apiKeyConfiguredWithHint: "已配置({hint})— 留空则保留",
|
||||
apiKeyNotConfigured: "粘贴你的 API 密钥",
|
||||
showApiKey: "显示 API 密钥",
|
||||
hideApiKey: "隐藏 API 密钥",
|
||||
paramsLabel: "Params(JSON 对象,可选)",
|
||||
paramsErrorPrefix: "Params:{message}",
|
||||
paramsErrors: {
|
||||
notObject: "params 必须是 JSON 对象",
|
||||
invalidJson: "无效的 JSON",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,10 @@ import panels from "./panels";
|
||||
import login from "./login";
|
||||
import errors from "./errors";
|
||||
import userManagement from "./userManagement";
|
||||
import changePassword from "./changePassword";
|
||||
import modelConfig from "./modelConfig";
|
||||
import imageModels from "./imageModels";
|
||||
import systemConfig from "./systemConfig";
|
||||
|
||||
export default {
|
||||
...common,
|
||||
@@ -18,4 +22,8 @@ export default {
|
||||
...login,
|
||||
...errors,
|
||||
...userManagement,
|
||||
...changePassword,
|
||||
...modelConfig,
|
||||
...imageModels,
|
||||
...systemConfig,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
export default {
|
||||
modelConfig: {
|
||||
title: "模型注册表",
|
||||
description:
|
||||
"管理提供商、模型、凭据和默认配置。仅限管理员;每次保存都会由后端校验。",
|
||||
collapseAgentActions: "默认折叠智能体操作",
|
||||
close: "关闭",
|
||||
registry: {
|
||||
requestFailed: "请求失败 ({status})。",
|
||||
minHint: "最小 {value}",
|
||||
maxHint: "最大 {value}",
|
||||
inheritPlaceholder: "继承",
|
||||
statusNew: "新",
|
||||
toggleParameters: "切换 {key} 的参数",
|
||||
enabled: "启用",
|
||||
saveBeforeTesting: "请先保存注册表再测试(测试仅针对已保存的模型运行)。",
|
||||
testing: "测试中…",
|
||||
test: "测试",
|
||||
remove: "移除",
|
||||
modelKey: "模型键",
|
||||
displayName: "显示名称",
|
||||
upstreamModelId: "上游模型 ID",
|
||||
contextWindow: "上下文窗口",
|
||||
maxOutputTokens: "最大输出 token 数",
|
||||
temperature: "温度",
|
||||
temperatureUnsupported: "温度(不支持)",
|
||||
capabilities: "能力",
|
||||
capabilityTools: "工具",
|
||||
capabilityVision: "视觉",
|
||||
capabilityStructuredOutput: "结构化输出",
|
||||
advanced: "高级",
|
||||
limitMode: "限额模式",
|
||||
maxInputTokens: "最大输入 token 数",
|
||||
minEffectiveInput: "最小有效输入",
|
||||
systemReserve: "系统预留",
|
||||
toolsReserve: "工具预留",
|
||||
attachmentsReserve: "附件预留",
|
||||
limitsStatus: "限额状态",
|
||||
limitsStatusNeedsConfirmation: "待确认",
|
||||
limitsStatusConfirmed: "已确认",
|
||||
limitsSource: "限额来源",
|
||||
limitsSourceUser: "用户",
|
||||
limitsSourceProvider: "提供商",
|
||||
limitsSourceTestedContract: "已测试契约",
|
||||
topP: "Top p",
|
||||
reasoningEffort: "推理强度",
|
||||
reasoningEffortUnsupported: "推理强度(不支持)",
|
||||
testPassedBanner:
|
||||
"测试通过,耗时 {latencyMs} ms —— 适配器规格版本 {revision},状态 {state}。",
|
||||
testFailedBanner: "测试失败{code}:{message}",
|
||||
removeProvider: "移除提供商",
|
||||
providerId: "提供商 ID",
|
||||
adapter: "适配器",
|
||||
baseUrl: "Base URL",
|
||||
baseUrlHint: "任意公网 HTTPS 端点,或已配置的开发端点之一。",
|
||||
authMode: "认证模式",
|
||||
apiKey: "API 密钥",
|
||||
apiKeyStaged: "已暂存 —— 将随注册表一起保存",
|
||||
apiKeyConfiguredWithHint: "已配置({hint})—— 留空则保持不变",
|
||||
apiKeyConfigured: "已配置 —— 留空则保持不变",
|
||||
apiKeyPaste: "粘贴你的 API 密钥",
|
||||
configuredPlaceholder: "已配置",
|
||||
showApiKey: "显示 API 密钥",
|
||||
hideApiKey: "隐藏 API 密钥",
|
||||
timeoutSeconds: "超时(秒)",
|
||||
maxRetries: "最大重试次数",
|
||||
defaultTemperature: "默认温度",
|
||||
defaultTopP: "默认 top p",
|
||||
defaultReasoningEffort: "默认推理强度",
|
||||
modelsHeading: "模型",
|
||||
addModel: "添加模型",
|
||||
noModels: "暂无模型。",
|
||||
loadFailed: "加载注册表失败。",
|
||||
changedElsewhere: "注册表已在别处被修改 —— 已重新加载最新状态。",
|
||||
saved: "注册表已保存(版本 {revision})。",
|
||||
saveFailed: "保存注册表失败。",
|
||||
primaryNeedsSecondModel: "修改默认主模型需要另一个已启用的模型来接替。",
|
||||
savedDisabled:
|
||||
"注册表已保存(版本 {revision});已修改的模型已被禁用 —— 请在准备好后测试并重新启用它们。",
|
||||
providerRejectedTest: "提供商拒绝了测试请求。",
|
||||
testPassedToast: "{key} 通过了提供商测试。",
|
||||
testFailedToast: "{key} 未通过提供商测试。",
|
||||
testFailedGeneric: "提供商测试失败。",
|
||||
forbidden: "模型配置需要管理员账号。",
|
||||
retry: "重试",
|
||||
loading: "正在加载注册表…",
|
||||
stateBadge: "状态:{state}",
|
||||
revisionBadge: "版本:{revision}",
|
||||
unsavedChanges: "未保存的更改",
|
||||
defaultPrimaryModel: "默认主模型",
|
||||
noneOption: "—— 无 ——",
|
||||
providers: "提供商",
|
||||
noProviders: "暂无提供商。请从下方模板添加。",
|
||||
providerEnabled: "提供商已启用",
|
||||
providerDisabled: "提供商已禁用",
|
||||
providerTemplate: "提供商模板",
|
||||
blankProvider: "空白提供商",
|
||||
addProvider: "添加提供商",
|
||||
templatesFootnote:
|
||||
"模板仅预填适配器、Base URL 和建议模型;保存后的提供商就是一个普通配置。",
|
||||
selectProvider: "在左侧选择一个提供商,或从模板添加。",
|
||||
requestPath: "(请求)",
|
||||
changedModelsDisabled: "已修改的模型将以禁用状态保存:{keys}",
|
||||
changedModelsExplanation:
|
||||
"启用模型需要其在当前配置下通过提供商测试。保存会禁用已修改的模型{repoint};请在准备好后对每个模型执行测试,然后重新启用并再次保存。",
|
||||
repointPrimary: ",并将默认主模型指向 {target}",
|
||||
saving: "保存中…",
|
||||
saveWithDisabled: "保存(已修改的模型将被禁用)",
|
||||
saveRegistry: "保存注册表",
|
||||
discardChanges: "放弃更改",
|
||||
idFootnote:
|
||||
"ID 由小写字母、数字、点、下划线和连字符组成(例如 <mono>zhipu-glm/glm-5.2</mono>):{pattern}。启用模型需要已确认的限额以及当前配置下通过的提供商测试。",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
export default {
|
||||
systemConfig: {
|
||||
title: "系统配置",
|
||||
description: "系统级参数。仅管理员可用;未设置的字段将回退到环境默认值。",
|
||||
loading: "加载中…",
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
saving: "保存中…",
|
||||
discardConfirm: "放弃未保存的更改?",
|
||||
saveFailed: "保存失败。",
|
||||
saveFailedStatus: "保存失败({status})。",
|
||||
requestFailed: "配置请求失败({status})。",
|
||||
tabs: {
|
||||
general: "常规",
|
||||
terms: "登录条款",
|
||||
security: "安全",
|
||||
backup: "数据备份",
|
||||
},
|
||||
general: {
|
||||
wordmark: "文字标识(左上角和登录页)",
|
||||
logo: "Logo",
|
||||
favicon: "网站图标",
|
||||
hint: "支持 PNG、JPEG 或 SVG,最大 512KB。网站图标还支持 ICO。文字标识在保存后生效;上传立即生效。",
|
||||
upload: "上传",
|
||||
restoreDefault: "恢复默认",
|
||||
uploadFailed: "上传失败。",
|
||||
restoreFailed: "恢复失败。",
|
||||
previewAlt: "{label} 预览",
|
||||
},
|
||||
security: {
|
||||
authEnabled: "WebUI 身份验证",
|
||||
sessionTtl: "会话有效期(小时)",
|
||||
passwordMinLength: "密码最小长度",
|
||||
captchaEnabled: "登录验证码",
|
||||
captchaThreshold: "登录失败 N 次后启用验证码",
|
||||
options: {
|
||||
default: "默认(环境变量)",
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
},
|
||||
confirmAuthOff:
|
||||
"禁用身份验证后,任何能访问该界面的人都可进入。确定继续吗?",
|
||||
confirmAuthOn:
|
||||
"启用身份验证需要在服务器环境中设置 WEBUI_AUTH_SECRET。若未设置,所有页面(包括本对话框)都会返回 503,恢复需要手动编辑 ~/.evoscientist/system-config.json。确定继续吗?",
|
||||
footnote:
|
||||
"留空则保持环境默认值。会话有效期仅适用于新签发的会话;现有会话保持其固定过期时间。",
|
||||
},
|
||||
loginTerms: {
|
||||
showTerms: "在登录页显示条款文本(仅展示)",
|
||||
termsLabel: "条款文本(Markdown)",
|
||||
placeholder: "例如:登录即表示您同意可接受使用政策…",
|
||||
preview: "预览",
|
||||
},
|
||||
backup: {
|
||||
backendDir: "后端数据目录",
|
||||
backendDirPlaceholder: "例如 /srv/evoscientist/data(可选)",
|
||||
scheduled: "定时备份",
|
||||
every: "每",
|
||||
hours: "小时",
|
||||
keep: "保留",
|
||||
history: "备份历史",
|
||||
runNow: "立即备份",
|
||||
running: "备份中…",
|
||||
empty: "暂无备份。",
|
||||
download: "下载",
|
||||
delete: "删除",
|
||||
confirmDelete: "删除 {name} 吗?",
|
||||
backupFailed: "备份失败。",
|
||||
deleteFailed: "删除失败({status})。",
|
||||
requestFailed: "备份请求失败({status})。",
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user