"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) ? ( ) : ( )}