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:
m4
2026-08-12 19:45:18 +08:00
parent 05d8abef2d
commit 4e66e6a7f4
28 changed files with 1023 additions and 334 deletions
+6 -1
View File
@@ -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
View File
@@ -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>
+15 -18
View File
@@ -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>
+11 -12
View File
@@ -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>
+7 -8
View File
@@ -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>
+5 -6
View File
@@ -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>
+62 -50
View File
@@ -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>
);
+206 -139
View File
@@ -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&apos;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>
);
+32 -21
View File
@@ -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>
+20 -12
View File
@@ -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 &amp; 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>
+14
View File
@@ -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...",
},
};
+14 -1
View File
@@ -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",
},
},
},
};
+1
View File
@@ -2,6 +2,7 @@ export default {
header: {
signOut: "Sign out",
configuration: "Configuration",
account: "Account",
userInformation: "User information",
changePassword: "Change password",
modelConfiguration: "Model configuration",
+60
View File
@@ -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",
},
},
},
};
+8
View File
@@ -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,
};
+118
View File
@@ -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.",
},
},
};
+75
View File
@@ -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}).",
},
},
};
+8
View File
@@ -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);
+13
View File
@@ -0,0 +1,13 @@
export default {
changePassword: {
title: "修改密码",
description: "修改密码后,除当前会话外的所有现有会话都将退出登录。",
currentPassword: "当前密码",
newPassword: "新密码",
confirmPassword: "确认新密码",
failed: "无法修改密码。",
cancel: "取消",
submit: "保存密码",
saving: "保存中...",
},
};
+14 -1
View File
@@ -1,3 +1,16 @@
export default {
dialogs: {},
dialogs: {
account: {
title: "用户信息",
description: "您在当前工作区登录的账号。",
detailsLabel: "账号详情",
username: "用户名",
loading: "正在加载账号...",
unavailable: "无法获取账号信息。",
roles: {
admin: "管理员",
user: "普通用户",
},
},
},
};
+1
View File
@@ -2,6 +2,7 @@ export default {
header: {
signOut: "退出登录",
configuration: "配置",
account: "账号",
userInformation: "用户信息",
changePassword: "修改密码",
modelConfiguration: "模型配置",
+58
View File
@@ -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",
},
},
},
};
+8
View File
@@ -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,
};
+115
View File
@@ -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}。启用模型需要已确认的限额以及当前配置下通过的提供商测试。",
},
},
};
+73
View File
@@ -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})。",
},
},
};