diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index da5cfe497..1989c7eb5 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1061,7 +1061,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record = imageRemoveBackground: Scissors, imageBoxCutout: Scan, imageLocalEdit: Wand2, - imagePsdTemplateReplace: ImageIcon, + imageTemplateReplace: ImageIcon, imageRemoveWatermark: Eraser, imageResize: Maximize2, imageTextEdit: Type, diff --git a/components/canvas/CanvasTemplateReplaceDialog.tsx b/components/canvas/CanvasTemplateReplaceDialog.tsx new file mode 100644 index 000000000..4129f620e --- /dev/null +++ b/components/canvas/CanvasTemplateReplaceDialog.tsx @@ -0,0 +1,930 @@ +"use client"; + +import { + Check, + CircleAlert, + ImagePlus, + Images, + LayoutTemplate, + Loader2, + Plus, + RefreshCw, + Trash2, + Upload, +} from "lucide-react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + createCanvasTemplateReplaceSlots, + MAX_CANVAS_TEMPLATE_REPLACE_SOURCES, + normalizeCanvasTemplateReplaceSources, + replaceCanvasTemplateSourceSlot, + type CanvasTemplateReplaceSource, +} from "@/lib/canvas/template-replace-batch"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { cn } from "@/lib/utils"; +import { imageGenerationService } from "@/services/canvas"; + +const TEMPLATE_REPLACE_POLL_INTERVAL_MS = 2500; + +type TemplateReplaceStatus = + | "waiting" + | "submitting" + | "queued" + | "processing" + | "completed" + | "failed"; + +type TemplateReplaceQueueStatus = { + queuePosition?: number; + sameModelRunningCount?: number; + concurrencyLimit?: number; + estimatedWaitMs?: number; +}; + +export type CanvasTemplateReplaceResult = { + id: string; + batchId: string; + source: CanvasTemplateReplaceSource; + referenceImageUrl: string; + resultImageUrl?: string; + taskId?: string; + status: TemplateReplaceStatus; + queueStatus?: TemplateReplaceQueueStatus; + error?: string; + addedToCanvas?: boolean; +}; + +type PersistedTemplateReplaceBatch = { + version: 1; + slots: Array; + referenceImageUrl: string; + supplementalPrompt: string; + results: CanvasTemplateReplaceResult[]; +}; + +type CanvasTemplateReplaceDialogProps = { + availableSources: CanvasTemplateReplaceSource[]; + canvasId: string; + initialSources: CanvasTemplateReplaceSource[]; + open: boolean; + projectId?: string | null; + triggerNodeId: string; + onAddToCanvas: ( + result: CanvasTemplateReplaceResult, + resultIndex: number + ) => void | Promise; + onOpenChange: (open: boolean) => void; +}; + +function isHttpImageUrl(value: unknown): value is string { + return /^https?:\/\//i.test(String(value || "").trim()); +} + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function createLocalId(prefix: string) { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function normalizeQueueStatus(value: unknown): TemplateReplaceQueueStatus | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const queue = value as Record; + const normalizeNumber = (entry: unknown) => { + const number = Number(entry); + return Number.isFinite(number) ? Math.max(0, number) : undefined; + }; + return { + queuePosition: normalizeNumber(queue.queuePosition), + sameModelRunningCount: normalizeNumber(queue.sameModelRunningCount), + concurrencyLimit: normalizeNumber(queue.concurrencyLimit), + estimatedWaitMs: normalizeNumber(queue.estimatedWaitMs), + }; +} + +function getStatusLabel(result: CanvasTemplateReplaceResult) { + if (result.status === "waiting") return "等待上一张"; + if (result.status === "submitting") return "正在提交"; + if (result.status === "queued") { + const position = Math.max(0, Number(result.queueStatus?.queuePosition || 0)); + return position > 0 ? `排队中(前方 ${position} 个)` : "排队中"; + } + if (result.status === "processing") return "处理中"; + if (result.status === "completed") return "已完成"; + return "失败"; +} + +function getPersistedBatch( + storageKey: string +): PersistedTemplateReplaceBatch | null { + if (typeof window === "undefined") return null; + try { + const raw = JSON.parse(window.localStorage.getItem(storageKey) || "null") as + | Partial + | null; + if (!raw || raw.version !== 1) return null; + + const slots = createCanvasTemplateReplaceSlots( + Array.isArray(raw.slots) ? raw.slots : [] + ); + const referenceImageUrl = isHttpImageUrl(raw.referenceImageUrl) + ? raw.referenceImageUrl.trim() + : ""; + const results = (Array.isArray(raw.results) ? raw.results : []) + .map((result): CanvasTemplateReplaceResult | null => { + if (!result || typeof result !== "object") return null; + const source = normalizeCanvasTemplateReplaceSources([result.source])[0]; + if (!source || !isHttpImageUrl(result.referenceImageUrl)) return null; + const taskId = String(result.taskId || "").trim() || undefined; + const resultImageUrl = isHttpImageUrl(result.resultImageUrl) + ? result.resultImageUrl.trim() + : undefined; + let status: TemplateReplaceStatus = "waiting"; + if (resultImageUrl) status = "completed"; + else if (result.status === "failed") status = "failed"; + else if (taskId) status = "queued"; + + return { + id: String(result.id || createLocalId("template-result")), + batchId: String(result.batchId || createLocalId("template-batch")), + source, + referenceImageUrl: result.referenceImageUrl.trim(), + resultImageUrl, + taskId, + status, + queueStatus: normalizeQueueStatus(result.queueStatus), + error: String(result.error || "").trim() || undefined, + addedToCanvas: result.addedToCanvas === true, + }; + }) + .filter((result): result is CanvasTemplateReplaceResult => Boolean(result)); + + return { + version: 1, + slots, + referenceImageUrl, + supplementalPrompt: String(raw.supplementalPrompt || "").slice(0, 2000), + results, + }; + } catch { + return null; + } +} + +export function CanvasTemplateReplaceDialog({ + availableSources, + canvasId, + initialSources, + open, + projectId, + triggerNodeId, + onAddToCanvas, + onOpenChange, +}: CanvasTemplateReplaceDialogProps) { + const uploadIdPrefix = useId(); + const storageKey = `canvas-template-replace:${canvasId}:${triggerNodeId}`; + const [slots, setSlots] = useState>( + () => createCanvasTemplateReplaceSlots(initialSources) + ); + const [referenceImageUrl, setReferenceImageUrl] = useState(""); + const [supplementalPrompt, setSupplementalPrompt] = useState(""); + const [results, setResults] = useState([]); + const [canvasPickerSlot, setCanvasPickerSlot] = useState(null); + const [canvasSearch, setCanvasSearch] = useState(""); + const [uploadingSlots, setUploadingSlots] = useState([]); + const [isUploadingReference, setIsUploadingReference] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const hydratedRef = useRef(false); + const skipNextPersistRef = useRef(false); + const runningRef = useRef(false); + const mountedRef = useRef(true); + const resultsRef = useRef([]); + + const setResultsWithRef = useCallback( + ( + updater: + | CanvasTemplateReplaceResult[] + | ((current: CanvasTemplateReplaceResult[]) => CanvasTemplateReplaceResult[]) + ) => { + setResults((current) => { + const next = typeof updater === "function" ? updater(current) : updater; + resultsRef.current = next; + return next; + }); + }, + [] + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!open || hydratedRef.current) return; + hydratedRef.current = true; + const persisted = getPersistedBatch(storageKey); + if (persisted) { + skipNextPersistRef.current = true; + setSlots(persisted.slots); + setReferenceImageUrl(persisted.referenceImageUrl); + setSupplementalPrompt(persisted.supplementalPrompt); + resultsRef.current = persisted.results; + setResults(persisted.results); + return; + } + setSlots(createCanvasTemplateReplaceSlots(initialSources)); + }, [initialSources, open, storageKey]); + + useEffect(() => { + if (!hydratedRef.current || typeof window === "undefined") return; + if (skipNextPersistRef.current) { + skipNextPersistRef.current = false; + return; + } + const payload: PersistedTemplateReplaceBatch = { + version: 1, + slots, + referenceImageUrl, + supplementalPrompt, + results, + }; + window.localStorage.setItem(storageKey, JSON.stringify(payload)); + }, [referenceImageUrl, results, slots, storageKey, supplementalPrompt]); + + const selectedSources = useMemo( + () => normalizeCanvasTemplateReplaceSources(slots), + [slots] + ); + const selectedUrls = useMemo( + () => new Set(selectedSources.map((source) => source.imageUrl)), + [selectedSources] + ); + const filteredCanvasSources = useMemo(() => { + const query = canvasSearch.trim().toLowerCase(); + return availableSources.filter( + (source) => + !query || + source.title.toLowerCase().includes(query) || + source.imageUrl.toLowerCase().includes(query) + ); + }, [availableSources, canvasSearch]); + const completedCount = results.filter((result) => result.status === "completed").length; + const canEditSources = !isProcessing && results.length === 0; + const canStart = + selectedSources.length > 0 && + isHttpImageUrl(referenceImageUrl) && + !isProcessing && + results.length === 0 && + uploadingSlots.length === 0 && + !isUploadingReference; + + const updateResult = useCallback( + (id: string, patch: Partial) => { + setResultsWithRef((current) => + current.map((result) => (result.id === id ? { ...result, ...patch } : result)) + ); + }, + [setResultsWithRef] + ); + + const uploadImage = useCallback(async (file: File, fallbackMessage: string) => { + if (!file.type.startsWith("image/")) { + throw new Error("请上传图片文件"); + } + const formData = new FormData(); + formData.append("file", file); + const response = await fetchUploadWithRetry(formData); + const payload = await response.json().catch(() => null); + const uploadedUrl = String(payload?.url || "").trim(); + if (!response.ok || !isHttpImageUrl(uploadedUrl)) { + throw new Error(payload?.error || fallbackMessage); + } + return uploadedUrl; + }, []); + + const handleSourceUpload = useCallback( + async (slotIndex: number, file?: File) => { + if (!file || !canEditSources) return; + setUploadingSlots((current) => [...new Set([...current, slotIndex])]); + try { + const imageUrl = await uploadImage(file, "替换素材上传失败"); + setSlots((current) => + replaceCanvasTemplateSourceSlot(current, slotIndex, { + id: createLocalId("template-upload"), + imageUrl, + title: file.name || `上传素材${slotIndex + 1}`, + origin: "upload", + }) + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "替换素材上传失败"); + } finally { + setUploadingSlots((current) => current.filter((index) => index !== slotIndex)); + } + }, + [canEditSources, uploadImage] + ); + + const handleReferenceUpload = useCallback( + async (file?: File) => { + if (!file || !canEditSources) return; + setIsUploadingReference(true); + try { + setReferenceImageUrl(await uploadImage(file, "目标模版图上传失败")); + } catch (error) { + toast.error(error instanceof Error ? error.message : "目标模版图上传失败"); + } finally { + setIsUploadingReference(false); + } + }, + [canEditSources, uploadImage] + ); + + const pollTask = useCallback( + async (resultId: string, taskId: string) => { + while (mountedRef.current) { + const taskResponse = await imageGenerationService.getImageTask(taskId); + const task = taskResponse.data?.task; + if (!taskResponse.success || !task) { + await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS); + continue; + } + + const status = String(task.status || "").toLowerCase(); + const queueStatus = normalizeQueueStatus(task.queueStatus); + if (status === "pending") { + updateResult(resultId, { status: "queued", queueStatus, error: undefined }); + } else if (status === "running" || status === "processing") { + updateResult(resultId, { status: "processing", queueStatus, error: undefined }); + } else if (status === "succeeded" || status === "completed") { + const image = task.result?.images?.[0]; + const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim(); + if (!isHttpImageUrl(imageUrl)) { + updateResult(resultId, { taskId: undefined }); + throw new Error("任务已完成,但没有返回结果图片"); + } + updateResult(resultId, { + status: "completed", + resultImageUrl: imageUrl, + queueStatus: undefined, + error: undefined, + }); + return; + } else if (["failed", "cancelled", "expired", "banned"].includes(status)) { + updateResult(resultId, { taskId: undefined }); + throw new Error(task.error || "模版替换生成失败"); + } + + await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS); + } + }, + [updateResult] + ); + + const runSequential = useCallback( + async (resultIds?: string[]) => { + if (runningRef.current) return; + runningRef.current = true; + setIsProcessing(true); + const requestedIds = resultIds ? new Set(resultIds) : null; + + try { + const orderedIds = resultsRef.current + .filter((result) => !requestedIds || requestedIds.has(result.id)) + .map((result) => result.id); + + for (const resultId of orderedIds) { + if (!mountedRef.current) return; + const current = resultsRef.current.find((result) => result.id === resultId); + if (!current || current.status === "completed") continue; + + try { + let taskId = current.taskId; + if (!taskId) { + updateResult(resultId, { + status: "submitting", + error: undefined, + queueStatus: undefined, + }); + const submitResult = await imageGenerationService.layoutReplace({ + sourceImageUrl: current.source.imageUrl, + referenceImageUrl: current.referenceImageUrl, + supplementalPrompt: supplementalPrompt.trim() || undefined, + sourceNodeId: current.source.sourceNodeId, + agentProjectId: projectId || undefined, + referenceLabel: "无限画布批量模版替换目标图", + imageSize: "2K", + debugMeta: { + operation: "canvas-template-replace-batch", + batchId: current.batchId, + canvasId, + sourceIndex: resultsRef.current.findIndex( + (result) => result.id === resultId + ), + }, + }); + taskId = String(submitResult.data?.taskId || "").trim(); + if (!submitResult.success || !taskId) { + throw new Error(submitResult.error || "模版替换提交失败"); + } + updateResult(resultId, { taskId, status: "queued" }); + } + await pollTask(resultId, taskId); + } catch (error) { + updateResult(resultId, { + status: "failed", + queueStatus: undefined, + error: error instanceof Error ? error.message : "模版替换生成失败", + }); + } + } + } finally { + runningRef.current = false; + if (mountedRef.current) setIsProcessing(false); + } + }, + [canvasId, pollTask, projectId, supplementalPrompt, updateResult] + ); + + useEffect(() => { + if ( + !open || + !hydratedRef.current || + runningRef.current || + !results.some((result) => + ["waiting", "submitting", "queued", "processing"].includes(result.status) + ) + ) { + return; + } + void runSequential(); + }, [open, results, runSequential]); + + const handleStart = useCallback(() => { + if (!canStart) return; + const batchId = createLocalId("template-batch"); + const nextResults = selectedSources.map( + (source, index): CanvasTemplateReplaceResult => ({ + id: createLocalId("template-result"), + batchId, + source, + referenceImageUrl: referenceImageUrl.trim(), + status: index === 0 ? "submitting" : "waiting", + }) + ); + resultsRef.current = nextResults; + setResults(nextResults); + toast.success(`已开始处理 ${nextResults.length} 张图片`); + void runSequential(nextResults.map((result) => result.id)); + }, [canStart, referenceImageUrl, runSequential, selectedSources]); + + const handleRetry = useCallback( + (result: CanvasTemplateReplaceResult) => { + if (isProcessing) return; + updateResult(result.id, { + status: result.taskId ? "queued" : "waiting", + error: undefined, + queueStatus: undefined, + }); + window.setTimeout(() => void runSequential([result.id]), 0); + }, + [isProcessing, runSequential, updateResult] + ); + + const handleAddResult = useCallback( + async (result: CanvasTemplateReplaceResult, resultIndex: number) => { + if (!result.resultImageUrl || result.addedToCanvas) return; + try { + await onAddToCanvas(result, resultIndex); + updateResult(result.id, { addedToCanvas: true }); + toast.success("结果已添加到画布"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "添加到画布失败"); + } + }, + [onAddToCanvas, updateResult] + ); + + const handleAddAll = useCallback(async () => { + const completedResults = resultsRef.current.filter( + (result) => result.status === "completed" && !result.addedToCanvas + ); + for (const result of completedResults) { + const resultIndex = resultsRef.current.findIndex((item) => item.id === result.id); + await handleAddResult(result, resultIndex); + } + }, [handleAddResult]); + + const handleReset = useCallback(() => { + if (isProcessing) return; + const nextSlots = createCanvasTemplateReplaceSlots(initialSources); + resultsRef.current = []; + setResults([]); + setSlots(nextSlots); + setReferenceImageUrl(""); + setSupplementalPrompt(""); + setCanvasPickerSlot(null); + if (typeof window !== "undefined") window.localStorage.removeItem(storageKey); + }, [initialSources, isProcessing, storageKey]); + + return ( + + + +
+
+ + + 模版替换 + + + 最多 5 张替换素材,共用 1 张目标模版图。 + +
+ {results.length > 0 && !isProcessing ? ( + + ) : null} +
+
+ +
+
+
+ + + {selectedSources.length}/{MAX_CANVAS_TEMPLATE_REPLACE_SOURCES} + +
+
+ {slots.map((source, slotIndex) => { + const isUploading = uploadingSlots.includes(slotIndex); + const uploadId = `${uploadIdPrefix}-source-${slotIndex}`; + return ( +
+ {source ? ( + <> + +
+ {slotIndex + 1}. {source.title} +
+ {canEditSources ? ( + + ) : null} + + ) : ( +
+ {isUploading ? ( + + ) : ( + + )} + 素材 {slotIndex + 1} + {canEditSources ? ( +
+ + +
+ ) : null} +
+ )} + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + void handleSourceUpload(slotIndex, file); + }} + type="file" + /> +
+ ); + })} +
+ + {canvasPickerSlot !== null && canEditSources ? ( +
+
+ setCanvasSearch(event.currentTarget.value)} + placeholder="搜索画布图片" + value={canvasSearch} + /> + +
+
+ {filteredCanvasSources.map((source) => { + const alreadySelected = selectedUrls.has(source.imageUrl); + return ( + + ); + })} +
+
+ ) : null} +
+ +
+
+ +
+ setReferenceImageUrl(event.currentTarget.value)} + placeholder="填写图片链接或上传" + value={referenceImageUrl} + /> + + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + void handleReferenceUpload(file); + }} + type="file" + /> +
+
+ {isUploadingReference ? ( + + ) : isHttpImageUrl(referenceImageUrl) ? ( + + ) : ( + + )} +
+ +