From 784a14351de57cbebebd97f587d7bc2d1af75286 Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 20:49:12 +0800 Subject: [PATCH] feat(components): add shared ConfigImportDialog for config import --- src/app/components/ConfigImportDialog.tsx | 331 ++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 src/app/components/ConfigImportDialog.tsx diff --git a/src/app/components/ConfigImportDialog.tsx b/src/app/components/ConfigImportDialog.tsx new file mode 100644 index 0000000..5fdbbf7 --- /dev/null +++ b/src/app/components/ConfigImportDialog.tsx @@ -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; +} + +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(null); + const [parsed, setParsed] = useState(null); + const [mode, setMode] = useState("replace"); + const [submitError, setSubmitError] = useState(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) => { + 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 = { + 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 ( + + + + + {kind === "model-registry" + ? t("dialog.titleRegistry") + : t("dialog.titleImage")} + + {t("dialog.description")} + +
+ +