feat(components): add shared ConfigImportDialog for config import

This commit is contained in:
m4
2026-08-12 20:49:12 +08:00
parent 3c3a327588
commit 784a14351d
+331
View File
@@ -0,0 +1,331 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
buildImageImportBody,
buildRegistryImportBody,
IMAGE_EXPORT_KIND,
imageMissingSecrets,
MAX_IMPORT_BYTES,
parseImageExport,
parseRegistryExport,
REGISTRY_EXPORT_KIND,
registryMissingSecrets,
type ImageExportEnvelope,
type ParseErrorCode,
type RegistryExportEnvelope,
} from "@/lib/configTransfer";
import type { GetModelRegistryResponse } from "@/lib/modelRegistry";
import {
getImageGeneration,
putImageGeneration,
type ImageGenerationSettings,
} from "@/lib/imageGeneration";
const inputClass =
"w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60";
type Kind = "model-registry" | "image-generation";
type Mode = "replace" | "merge";
interface ConfigImportDialogProps {
kind: Kind;
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => Promise<void> | void;
}
interface ParsedState {
registry: RegistryExportEnvelope | null;
image: ImageExportEnvelope | null;
missing: string[];
}
const EMPTY_IMAGE_SETTINGS: ImageGenerationSettings = {
default_model: "",
timeout_seconds: 0,
models: [],
};
export function ConfigImportDialog({
kind,
open,
onOpenChange,
onImported,
}: ConfigImportDialogProps) {
const t = useTranslations("configTransfer");
const [text, setText] = useState("");
const [parseError, setParseError] = useState<ParseErrorCode | null>(null);
const [parsed, setParsed] = useState<ParsedState | null>(null);
const [mode, setMode] = useState<Mode>("replace");
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) {
setText("");
setParseError(null);
setParsed(null);
setMode("replace");
setSubmitError(null);
setSubmitting(false);
}
}, [open]);
const analyze = useCallback(
async (raw: string) => {
setText(raw);
setSubmitError(null);
if (!raw.trim()) {
setParseError(null);
setParsed(null);
return;
}
if (kind === "model-registry") {
const result = parseRegistryExport(raw);
if (!result.ok) {
setParseError(result.code);
setParsed(null);
return;
}
setParseError(null);
setParsed({
registry: result.value,
image: null,
missing: registryMissingSecrets(result.value),
});
} else {
const result = parseImageExport(raw);
if (!result.ok) {
setParseError(result.code);
setParsed(null);
return;
}
// Current stored keys decide which empty file keys are real gaps.
let current = EMPTY_IMAGE_SETTINGS;
try {
current = await getImageGeneration();
} catch {
// Conservative: every empty key is flagged.
}
setParseError(null);
setParsed({
registry: null,
image: result.value,
missing: imageMissingSecrets(result.value, current),
});
}
},
[kind]
);
const onFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > MAX_IMPORT_BYTES) {
setText("");
setParsed(null);
setParseError("too_large");
return;
}
await analyze(await file.text());
};
const submit = async () => {
if (!parsed) return;
setSubmitting(true);
setSubmitError(null);
try {
if (kind === "model-registry" && parsed.registry) {
const currentResponse = await fetch("/api/model-registry", {
cache: "no-store",
});
if (!currentResponse.ok) {
throw new Error(
t("dialog.loadCurrentFailed", {
status: String(currentResponse.status),
})
);
}
const current = (await currentResponse.json()) as GetModelRegistryResponse;
const body = buildRegistryImportBody(parsed.registry, current, mode);
const putResponse = await fetch("/api/model-registry", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!putResponse.ok) {
const errorBody = await putResponse.json().catch(() => null);
const message =
typeof errorBody?.message === "string"
? errorBody.message
: `(${putResponse.status})`;
throw new Error(t("dialog.failed", { message }));
}
} else if (kind === "image-generation" && parsed.image) {
const current = await getImageGeneration();
await putImageGeneration(buildImageImportBody(parsed.image, current, mode));
}
toast.success(t("dialog.success"));
onOpenChange(false);
await onImported();
} catch (reason) {
setSubmitError(reason instanceof Error ? reason.message : String(reason));
} finally {
setSubmitting(false);
}
};
const parseErrorMessages: Record<ParseErrorCode, string> = {
too_large: t("dialog.errors.too_large"),
invalid_json: t("dialog.errors.invalid_json"),
wrong_kind: t("dialog.errors.wrong_kind", {
expected: kind === "model-registry" ? REGISTRY_EXPORT_KIND : IMAGE_EXPORT_KIND,
}),
unsupported_version: t("dialog.errors.unsupported_version"),
invalid_shape: t("dialog.errors.invalid_shape"),
};
const preview =
parsed?.registry != null
? t("dialog.previewRegistry", {
providers: String(parsed.registry.payload.registry.providers.length),
models: String(
parsed.registry.payload.registry.providers.reduce(
(count, provider) => count + provider.models.length,
0
)
),
})
: parsed?.image != null
? t("dialog.previewImage", {
models: String(parsed.image.payload.models.length),
})
: null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{kind === "model-registry"
? t("dialog.titleRegistry")
: t("dialog.titleImage")}
</DialogTitle>
<DialogDescription>{t("dialog.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="block space-y-0.5">
<span className="text-xs font-medium text-muted-foreground">
{t("dialog.fileLabel")}
</span>
<input
type="file"
accept=".json,application/json"
className="text-sm"
onChange={(event) => void onFileChange(event)}
/>
</label>
<label className="block space-y-0.5">
<span className="text-xs font-medium text-muted-foreground">
{t("dialog.pasteLabel")}
</span>
<textarea
className={`${inputClass} font-mono text-xs`}
rows={6}
value={text}
placeholder={t("dialog.pastePlaceholder")}
spellCheck={false}
onChange={(event) => void analyze(event.target.value)}
/>
</label>
{parseError && (
<p role="alert" className="text-xs text-destructive">
{parseErrorMessages[parseError]}
</p>
)}
{parsed && (
<div className="space-y-2 rounded-md border border-border p-3 text-xs">
<p className="font-medium">{preview}</p>
{parsed.missing.length > 0 && (
<div className="space-y-0.5 text-[var(--color-warning)]">
<p>{t("dialog.missingSecretsTitle")}</p>
<ul className="list-inside list-disc">
{parsed.missing.map((id) => (
<li key={id} className="font-mono">
{id}
</li>
))}
</ul>
</div>
)}
<p className="text-muted-foreground">{t("dialog.secretsNotice")}</p>
<fieldset className="space-y-1">
<legend className="text-xs font-medium text-muted-foreground">
{t("dialog.modeLabel")}
</legend>
{(["replace", "merge"] as const).map((value) => (
<label key={value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name="import-mode"
className="mt-1"
checked={mode === value}
onChange={() => setMode(value)}
/>
<span>
<span className="font-medium">
{value === "replace"
? t("dialog.modeReplace")
: t("dialog.modeMerge")}
</span>{" "}
<span className="text-muted-foreground">
{value === "replace"
? t("dialog.modeReplaceHint")
: t("dialog.modeMergeHint")}
</span>
</span>
</label>
))}
</fieldset>
</div>
)}
{submitError && (
<p role="alert" className="text-xs text-destructive">
{submitError}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={() => onOpenChange(false)}
>
{t("dialog.cancel")}
</Button>
<Button
type="button"
disabled={!parsed || submitting}
onClick={() => void submit()}
>
{submitting ? t("dialog.submitting") : t("dialog.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}