feat(canvas): add batch template replacement

This commit is contained in:
134 2026-08-13 23:29:08 +08:00
parent 3009727ec1
commit bf6bd01fcb
14 changed files with 1278 additions and 236 deletions

View File

@ -1061,7 +1061,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageRemoveBackground: Scissors,
imageBoxCutout: Scan,
imageLocalEdit: Wand2,
imagePsdTemplateReplace: ImageIcon,
imageTemplateReplace: ImageIcon,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,

View File

@ -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<CanvasTemplateReplaceSource | null>;
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<void>;
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<string, unknown>;
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<PersistedTemplateReplaceBatch>
| 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<Array<CanvasTemplateReplaceSource | null>>(
() => createCanvasTemplateReplaceSlots(initialSources)
);
const [referenceImageUrl, setReferenceImageUrl] = useState("");
const [supplementalPrompt, setSupplementalPrompt] = useState("");
const [results, setResults] = useState<CanvasTemplateReplaceResult[]>([]);
const [canvasPickerSlot, setCanvasPickerSlot] = useState<number | null>(null);
const [canvasSearch, setCanvasSearch] = useState("");
const [uploadingSlots, setUploadingSlots] = useState<number[]>([]);
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<CanvasTemplateReplaceResult[]>([]);
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<CanvasTemplateReplaceResult>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1080px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base">
<LayoutTemplate className="h-4 w-4" />
模版替换
</DialogTitle>
<DialogDescription className="mt-1">
最多 5 张替换素材,共用 1 张目标模版图。
</DialogDescription>
</div>
{results.length > 0 && !isProcessing ? (
<Button className="shrink-0" onClick={handleReset} size="sm" variant="outline">
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
新建一批
</Button>
) : null}
</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>替换素材</Label>
<span className="text-muted-foreground text-xs">
{selectedSources.length}/{MAX_CANVAS_TEMPLATE_REPLACE_SOURCES}
</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{slots.map((source, slotIndex) => {
const isUploading = uploadingSlots.includes(slotIndex);
const uploadId = `${uploadIdPrefix}-source-${slotIndex}`;
return (
<div
className="group relative aspect-square min-w-0 overflow-hidden rounded-lg border bg-muted/30"
key={`source-slot-${slotIndex}`}
>
{source ? (
<>
<AgentPreviewImage
alt={source.title}
className="h-full w-full object-cover"
previewQuality={78}
previewWidth={360}
src={source.imageUrl}
/>
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
{slotIndex + 1}. {source.title}
</div>
{canEditSources ? (
<button
aria-label={`移除素材 ${slotIndex + 1}`}
className="absolute top-1.5 right-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/70 text-white opacity-100 hover:bg-black sm:opacity-0 sm:group-hover:opacity-100"
onClick={() =>
setSlots((current) =>
replaceCanvasTemplateSourceSlot(current, slotIndex, null)
)
}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 p-2 text-muted-foreground">
{isUploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ImagePlus className="h-5 w-5" />
)}
<span className="text-xs">素材 {slotIndex + 1}</span>
{canEditSources ? (
<div className="flex gap-1">
<Button
className="h-7 px-2 text-xs"
onClick={() => setCanvasPickerSlot(slotIndex)}
size="sm"
type="button"
variant="outline"
>
<Images className="mr-1 h-3 w-3" />
画布
</Button>
<Button
asChild
className="h-7 px-2 text-xs"
size="sm"
variant="outline"
>
<label htmlFor={uploadId}>
<Upload className="h-3 w-3" />
</label>
</Button>
</div>
) : null}
</div>
)}
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="sr-only"
disabled={!canEditSources || isUploading}
id={uploadId}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleSourceUpload(slotIndex, file);
}}
type="file"
/>
</div>
);
})}
</div>
{canvasPickerSlot !== null && canEditSources ? (
<div className="rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-2">
<Input
className="h-9"
onChange={(event) => setCanvasSearch(event.currentTarget.value)}
placeholder="搜索画布图片"
value={canvasSearch}
/>
<Button
className="shrink-0"
onClick={() => setCanvasPickerSlot(null)}
size="sm"
type="button"
variant="outline"
>
取消
</Button>
</div>
<div className="grid max-h-52 grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-5 md:grid-cols-7">
{filteredCanvasSources.map((source) => {
const alreadySelected = selectedUrls.has(source.imageUrl);
return (
<button
className={cn(
"relative aspect-square overflow-hidden rounded-lg border bg-muted/30 text-left",
alreadySelected
? "cursor-not-allowed opacity-40"
: "hover:border-primary"
)}
disabled={alreadySelected}
key={source.id}
onClick={() => {
setSlots((current) =>
replaceCanvasTemplateSourceSlot(
current,
canvasPickerSlot,
source
)
);
setCanvasPickerSlot(null);
setCanvasSearch("");
}}
title={source.title}
type="button"
>
<AgentPreviewImage
alt={source.title}
className="h-full w-full object-cover"
previewQuality={72}
previewWidth={240}
src={source.imageUrl}
/>
<span className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-1.5 py-1 text-[10px] text-white">
{source.title}
</span>
</button>
);
})}
</div>
</div>
) : null}
</section>
<section className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-2">
<Label htmlFor={`${uploadIdPrefix}-reference`}>目标模版图</Label>
<div className="flex min-w-0 gap-2">
<Input
className="min-w-0"
disabled={!canEditSources}
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
placeholder="填写图片链接或上传"
value={referenceImageUrl}
/>
<Button
asChild
className="shrink-0"
variant="outline"
>
<label htmlFor={`${uploadIdPrefix}-reference`}>
{isUploadingReference ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1.5 h-4 w-4" />
)}
上传
</label>
</Button>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="sr-only"
disabled={!canEditSources || isUploadingReference}
id={`${uploadIdPrefix}-reference`}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleReferenceUpload(file);
}}
type="file"
/>
</div>
<div className="flex h-[clamp(160px,28dvh,280px)] items-center justify-center overflow-hidden rounded-lg border border-dashed bg-muted/20">
{isUploadingReference ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : isHttpImageUrl(referenceImageUrl) ? (
<AgentPreviewImage
alt="目标模版图"
className="max-h-full max-w-full object-contain"
previewQuality={82}
previewWidth={720}
src={referenceImageUrl}
/>
) : (
<LayoutTemplate className="h-8 w-8 text-muted-foreground/45" />
)}
</div>
<Label htmlFor={`${uploadIdPrefix}-prompt`}>补充要求</Label>
<Textarea
className="min-h-20 max-h-36"
disabled={!canEditSources}
id={`${uploadIdPrefix}-prompt`}
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
placeholder="可选:补充材质、颜色或替换细节"
value={supplementalPrompt}
/>
</div>
<div className="min-w-0 space-y-2">
<div className="flex items-center justify-between gap-3">
<Label>生图结果</Label>
{results.length > 0 ? (
<span className="text-muted-foreground text-xs">
{completedCount}/{results.length}
</span>
) : null}
</div>
{results.length === 0 ? (
<div className="flex h-[clamp(220px,42dvh,420px)] items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground">
<div className="text-center">
<Images className="mx-auto mb-2 h-7 w-7 opacity-45" />
<p className="text-sm">待生成</p>
</div>
</div>
) : (
<div className="grid max-h-[clamp(300px,52dvh,560px)] grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{results.map((result, resultIndex) => {
const active = ["submitting", "queued", "processing"].includes(
result.status
);
return (
<div
className="min-w-0 overflow-hidden rounded-lg border bg-background"
key={result.id}
>
<div className="relative aspect-[4/3] bg-muted/30">
<AgentPreviewImage
alt={`${result.source.title}结果`}
className="h-full w-full object-contain"
previewQuality={82}
previewWidth={640}
src={result.resultImageUrl || result.source.imageUrl}
/>
{result.status !== "completed" ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-[1px]">
<div className="max-w-[85%] text-center">
{active ? (
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
) : result.status === "failed" ? (
<CircleAlert className="mx-auto mb-2 h-5 w-5 text-destructive" />
) : (
<Loader2 className="mx-auto mb-2 h-5 w-5 text-muted-foreground" />
)}
<p className="text-sm">{getStatusLabel(result)}</p>
{result.error ? (
<p className="mt-1 line-clamp-2 text-destructive text-xs">
{result.error}
</p>
) : null}
</div>
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-2 p-2">
<span className="min-w-0 truncate text-xs">
{resultIndex + 1}. {result.source.title}
</span>
{result.status === "completed" ? (
<Button
className="h-8 shrink-0 px-2 text-xs"
disabled={result.addedToCanvas}
onClick={() => void handleAddResult(result, resultIndex)}
size="sm"
type="button"
variant="outline"
>
{result.addedToCanvas ? (
<Check className="mr-1 h-3.5 w-3.5" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
{result.addedToCanvas ? "已添加" : "添加到画布"}
</Button>
) : result.status === "failed" ? (
<Button
className="h-8 shrink-0 px-2 text-xs"
disabled={isProcessing}
onClick={() => handleRetry(result)}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
重试
</Button>
) : (
<span className="shrink-0 text-muted-foreground text-xs">
{getStatusLabel(result)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</section>
</div>
<DialogFooter className="shrink-0 gap-2 border-t bg-background px-4 py-3 sm:px-6">
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
{isProcessing ? "收起" : "关闭"}
</Button>
{completedCount > 0 ? (
<Button
disabled={!results.some(
(result) => result.status === "completed" && !result.addedToCanvas
)}
onClick={() => void handleAddAll()}
type="button"
variant="outline"
>
<Plus className="mr-1.5 h-4 w-4" />
全部添加到画布
</Button>
) : null}
<Button disabled={!canStart} onClick={handleStart} type="button">
{isProcessing ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-1.5 h-4 w-4" />
)}
{isProcessing
? `处理中 ${completedCount}/${results.length}`
: `批量处理${selectedSources.length ? `(${selectedSources.length})` : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -716,14 +716,14 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLocalEdit"
);
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
const selectedImageTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
"imageTemplateReplace"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
const selectedImagePsdTemplateReplaceEnabled = true;
const selectedImageTemplateReplaceEnabled = true;
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
@ -2536,7 +2536,7 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
{selectedImagePsdTemplateReplaceEnabled &&
{selectedImageTemplateReplaceEnabled &&
selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
@ -2547,16 +2547,16 @@ export function CanvasWorkspace({
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
requestImageNodeAction(selectedImageNodes[0].id, "template-replace")
}
title={selectedImagePsdTemplateReplaceItem.label}
title={selectedImageTemplateReplaceItem.label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={FileImage}
item={selectedImagePsdTemplateReplaceItem}
item={selectedImageTemplateReplaceItem}
/>
{selectedImagePsdTemplateReplaceItem.label}
{selectedImageTemplateReplaceItem.label}
</Button>
) : null}
<Button

View File

@ -52,6 +52,10 @@ import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceO
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import {
CanvasTemplateReplaceDialog,
type CanvasTemplateReplaceResult,
} from "@/components/canvas/CanvasTemplateReplaceDialog";
import {
areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle,
@ -132,15 +136,10 @@ import {
storeCanvasAgentAnnotationEditRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import {
createCanvasPsdTemplateLaunchToken,
saveCanvasPsdTemplateLaunchPayload,
} from "@/lib/canvas/psd-template-launch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -226,6 +225,7 @@ import {
} from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import type { CanvasTemplateReplaceSource } from "@/lib/canvas/template-replace-batch";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
@ -1357,9 +1357,9 @@ export function ImageNode({
);
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
const imageTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
"imageTemplateReplace"
);
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
@ -1433,8 +1433,8 @@ export function ImageNode({
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
// 也必须默认展示,避免历史配置把新功能吞掉。
const imageLocalEditEnabled = true;
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
const imagePsdTemplateReplaceEnabled = true;
// 模版替换是无限画布图片的固定能力,历史配置缺少新 key 时仍保持可见。
const imageTemplateReplaceEnabled = true;
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageLayerSplit"
@ -1500,7 +1500,7 @@ export function ImageNode({
{ key: "textEdit", enabled: imageTextEditEnabled },
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
{ key: "localEdit", enabled: imageLocalEditEnabled },
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
{ key: "templateReplace", enabled: imageTemplateReplaceEnabled },
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
{ key: "resize", enabled: imageResizeEnabled },
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
@ -1536,7 +1536,7 @@ export function ImageNode({
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
imageEnhanceEnabled ||
imagePsdTemplateReplaceEnabled ||
imageTemplateReplaceEnabled ||
imageNoteEnabled ||
hasCanvasAgentAnnotations ||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
@ -1695,37 +1695,7 @@ export function ImageNode({
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
useSWR<{
success?: boolean;
templates?: Array<{ brandName?: string | null }>;
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
const availablePsdTemplateBrands = useMemo(
() =>
Array.from(
new Set(
(psdTemplateSummary?.templates || [])
.map((template) => String(template?.brandName || "").trim())
.filter(Boolean)
)
),
[psdTemplateSummary?.templates]
);
useEffect(() => {
if (
psdTemplateDialogOpen &&
!psdTemplateBrandName.trim() &&
availablePsdTemplateBrands.length === 1
) {
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
}
}, [
availablePsdTemplateBrands,
psdTemplateBrandName,
psdTemplateDialogOpen,
]);
const [templateReplaceDialogOpen, setTemplateReplaceDialogOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
@ -2354,6 +2324,51 @@ export function ImageNode({
y: number;
} | null>(null);
const displayTitle = getImageNodeTitle(data, node.id);
const availableTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(
() =>
allNodes.flatMap((canvasNode) => {
if (canvasNode.type !== "image") return [];
const imageData = canvasNode.data as ImageNodeData;
if (!isHttpImageUrl(imageData.src)) return [];
return [
{
id: canvasNode.id,
imageUrl: imageData.src,
title: getImageNodeTitle(imageData, canvasNode.id),
origin: "canvas" as const,
sourceNodeId: canvasNode.id,
},
];
}),
[allNodes]
);
const initialTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(() => {
const sourceByNodeId = new Map(
availableTemplateReplaceSources.map((source) => [source.sourceNodeId, source])
);
const currentSource = sourceByNodeId.get(node.id) ||
(isHttpImageUrl(data.src)
? {
id: node.id,
imageUrl: data.src,
title: displayTitle,
origin: "canvas" as const,
sourceNodeId: node.id,
}
: null);
return [
currentSource,
...selectedNodeIds
.filter((selectedNodeId) => selectedNodeId !== node.id)
.map((selectedNodeId) => sourceByNodeId.get(selectedNodeId) || null),
].filter((source): source is CanvasTemplateReplaceSource => Boolean(source));
}, [
availableTemplateReplaceSources,
data.src,
displayTitle,
node.id,
selectedNodeIds,
]);
const agentExecutionSnapshotId =
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
@ -6098,53 +6113,18 @@ export function ImageNode({
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
const handleOpenPsdTemplateReplace = useCallback(() => {
const handleOpenTemplateReplace = useCallback(() => {
if (!canvasId) {
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
toast.error("当前图片未绑定画布,暂时无法进行模版替换");
return;
}
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("图片上传完成后再进行模版替换");
return;
}
setPsdTemplateDialogOpen(true);
setTemplateReplaceDialogOpen(true);
}, [canvasId, data.src]);
const handleLaunchPsdTemplateReplace = useCallback(() => {
const normalizedBrandName = psdTemplateBrandName.trim();
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
toast.error("当前图片暂时无法进行模版替换");
return;
}
if (!normalizedBrandName) {
toast.error("请先选择或输入品牌名");
return;
}
const token = createCanvasPsdTemplateLaunchToken();
const returnTo =
typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}`
: `/canvas/${canvasId}`;
saveCanvasPsdTemplateLaunchPayload(token, {
brandName: normalizedBrandName,
pendingImages: [
{
id: node.id,
imageUrl: data.src,
title: displayTitle,
},
],
activeImageId: node.id,
returnTo,
templateMode: "main",
createdAt: Date.now(),
});
setPsdTemplateDialogOpen(false);
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
@ -6167,14 +6147,14 @@ export function ImageNode({
return;
}
if (imageNodeActionRequest.action === "psd-template") {
handleOpenPsdTemplateReplace();
if (imageNodeActionRequest.action === "template-replace") {
handleOpenTemplateReplace();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenPsdTemplateReplace,
handleOpenTemplateReplace,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
@ -6926,8 +6906,8 @@ export function ImageNode({
handleEnterLocalEditMode();
return;
}
if (action === "psd-template") {
handleOpenPsdTemplateReplace();
if (action === "template-replace") {
handleOpenTemplateReplace();
return;
}
if (action === "scale-adjust") {
@ -7098,6 +7078,55 @@ export function ImageNode({
]
);
const handleTemplateReplaceAddToCanvas = useCallback(
(result: CanvasTemplateReplaceResult, resultIndex: number) => {
const resultImageUrl = String(result.resultImageUrl || "").trim();
if (!isHttpImageUrl(resultImageUrl)) {
throw new Error("模版替换结果地址不可用");
}
const gap = 24;
const column = resultIndex % 2;
const row = Math.floor(resultIndex / 2);
const addedNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + gap + column * (node.size.width + gap),
y: node.position.y + row * (node.size.height + gap),
},
size: { width: node.size.width, height: node.size.height },
data: {
src: resultImageUrl,
status: "completed",
prompt: "模版替换结果",
title: `${result.source.title}-模版替换`,
originalSrc: result.source.imageUrl,
metadata: {
operation: "canvas-template-replace-batch",
batchId: result.batchId,
taskId: result.taskId,
sourceNodeId: result.source.sourceNodeId,
sourceImageUrl: result.source.imageUrl,
referenceImageUrl: result.referenceImageUrl,
},
},
connections: result.source.sourceNodeId
? [result.source.sourceNodeId]
: [node.id],
});
selectNode(addedNodeId);
},
[
addNode,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
selectNode,
]
);
const handleLayerSplitComplete = useCallback(
(
layers: Array<{
@ -9695,14 +9724,14 @@ export function ImageNode({
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imagePsdTemplateReplaceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
{imageTemplateReplaceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
item={imageTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
<span>{imageTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{imageExtendEnabled ? (
@ -9856,89 +9885,16 @@ export function ImageNode({
prompt={regeneratePrompt}
/>
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileImage className="h-5 w-5" />
模版替换
</DialogTitle>
<DialogDescription>
当前图片会作为替换素材带入 PSD 模版工作台。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
<img
alt={displayTitle}
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
src={buildImageProxyUrl(data.src, { width: 240 })}
/>
<div className="min-w-0 self-center">
<div className="truncate font-medium text-sm">{displayTitle}</div>
<div className="mt-1 text-muted-foreground text-xs">
已选择 1 张画布图片
</div>
</div>
</div>
{isLoadingPsdTemplateSummary ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在读取模版品牌...
</div>
) : availablePsdTemplateBrands.length > 0 ? (
<div className="space-y-2">
<Label>快捷选择</Label>
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
{availablePsdTemplateBrands.map((brand) => {
const active = psdTemplateBrandName.trim() === brand;
return (
<button
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted"
)}
key={brand}
onClick={() => setPsdTemplateBrandName(brand)}
type="button"
>
{brand}
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
<Input
id={`psd-template-brand-${node.id}`}
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
placeholder="选择或输入已配置 PSD 模版的品牌名"
value={psdTemplateBrandName}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
取消
</Button>
<Button
disabled={!psdTemplateBrandName.trim()}
onClick={handleLaunchPsdTemplateReplace}
>
<FileImage className="mr-2 h-4 w-4" />
打开模版替换
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CanvasTemplateReplaceDialog
availableSources={availableTemplateReplaceSources}
canvasId={canvasId}
initialSources={initialTemplateReplaceSources}
onAddToCanvas={handleTemplateReplaceAddToCanvas}
onOpenChange={setTemplateReplaceDialogOpen}
open={templateReplaceDialogOpen}
projectId={projectId}
triggerNodeId={node.id}
/>
<ThreeDGenerationDialog
costLabel={
@ -10517,20 +10473,20 @@ export function ImageNode({
</Button>
) : null}
{isSelectedImageActionInline("psdTemplate") ? (
{isSelectedImageActionInline("templateReplace") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("psd-template")}
title={imagePsdTemplateReplaceItem.label}
onClick={() => handleAction("template-replace")}
title={imageTemplateReplaceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
item={imageTemplateReplaceItem}
/>
{imagePsdTemplateReplaceItem.label}
{imageTemplateReplaceItem.label}
</Button>
) : null}
@ -10808,14 +10764,14 @@ export function ImageNode({
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("psdTemplate") ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
{isSelectedImageActionInMore("templateReplace") ? (
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
item={imageTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
<span>{imageTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (

View File

@ -221,10 +221,10 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
description: "框选多个彩色区域并按区域提示词局部改图",
},
{
key: "imagePsdTemplateReplace",
key: "imageTemplateReplace",
group: "节点工具栏",
label: "模版替换",
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
description: "用当前图片和最多四张补充图片批量替换目标模版中的主体",
},
{
key: "imageRemoveWatermark",
@ -785,11 +785,16 @@ export function normalizeCanvasToolbarConfig(
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
const legacyRawItem =
defaultItem.key === "imageTemplateReplace"
? rawItems.imagePsdTemplateReplace
: undefined;
const rawItemValue = rawItems[defaultItem.key] ?? legacyRawItem;
const rawItem =
rawItems[defaultItem.key] &&
typeof rawItems[defaultItem.key] === "object" &&
!Array.isArray(rawItems[defaultItem.key])
? (rawItems[defaultItem.key] as Record<string, unknown>)
rawItemValue &&
typeof rawItemValue === "object" &&
!Array.isArray(rawItemValue)
? (rawItemValue as Record<string, unknown>)
: {};
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
const iconUrl = normalizeString(rawItem.iconUrl, 2000);

View File

@ -208,12 +208,12 @@ export const CANVAS_CAPABILITY_REGISTRY = [
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
}),
defineCapability({
id: "image.psd_template_replace",
id: "image.template_replace",
title: "模版替换",
description: "把选中图片带入已配置的 PSD 模版工作台进行素材替换。",
clientAction: "psd-template",
toolbarActionKey: "psdTemplate",
toolbarSettingKey: "imagePsdTemplateReplace",
description: "使用选中图片与目标模版图执行批量主体替换。",
clientAction: "template-replace",
toolbarActionKey: "templateReplace",
toolbarSettingKey: "imageTemplateReplace",
executionMode: "interactive",
risk: "write",
modelPolicy: "none",

View File

@ -9,7 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
"image.text_edit",
"image.box_cutout",
"image.local_edit",
"image.psd_template_replace",
"image.template_replace",
"image.scale_adjust",
"image.remove_watermark",
"image.resize",
@ -39,7 +39,7 @@ export type CanvasCapabilityClientAction =
| "text-edit"
| "box-cutout"
| "local-edit"
| "psd-template"
| "template-replace"
| "scale-adjust"
| "watermark"
| "resize"
@ -65,7 +65,7 @@ export type SelectedImageToolbarActionKey =
| "textEdit"
| "boxCutout"
| "localEdit"
| "psdTemplate"
| "templateReplace"
| "removeWatermark"
| "resize"
| "multiAngle"

View File

@ -0,0 +1,79 @@
export const MAX_CANVAS_TEMPLATE_REPLACE_SOURCES = 5;
export type CanvasTemplateReplaceSource = {
id: string;
imageUrl: string;
title: string;
origin: "canvas" | "upload";
sourceNodeId?: string;
};
function normalizeSource(
source: CanvasTemplateReplaceSource | null | undefined
): CanvasTemplateReplaceSource | null {
if (!source) return null;
const imageUrl = String(source.imageUrl || "").trim();
if (!/^https?:\/\//i.test(imageUrl)) return null;
return {
id: String(source.id || imageUrl).trim() || imageUrl,
imageUrl,
title: String(source.title || "替换素材").trim() || "替换素材",
origin: source.origin === "upload" ? "upload" : "canvas",
sourceNodeId: String(source.sourceNodeId || "").trim() || undefined,
};
}
export function normalizeCanvasTemplateReplaceSources(
sources: Array<CanvasTemplateReplaceSource | null | undefined>
): CanvasTemplateReplaceSource[] {
const seenUrls = new Set<string>();
const normalized: CanvasTemplateReplaceSource[] = [];
for (const source of sources) {
const nextSource = normalizeSource(source);
if (!nextSource || seenUrls.has(nextSource.imageUrl)) continue;
seenUrls.add(nextSource.imageUrl);
normalized.push(nextSource);
if (normalized.length >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) break;
}
return normalized;
}
export function createCanvasTemplateReplaceSlots(
sources: Array<CanvasTemplateReplaceSource | null | undefined>
): Array<CanvasTemplateReplaceSource | null> {
const slots = normalizeCanvasTemplateReplaceSources(sources);
return Array.from(
{ length: MAX_CANVAS_TEMPLATE_REPLACE_SOURCES },
(_, index) => slots[index] || null
);
}
export function replaceCanvasTemplateSourceSlot(
slots: Array<CanvasTemplateReplaceSource | null>,
slotIndex: number,
source: CanvasTemplateReplaceSource | null
): Array<CanvasTemplateReplaceSource | null> {
const nextSlots = createCanvasTemplateReplaceSlots([]);
slots.slice(0, MAX_CANVAS_TEMPLATE_REPLACE_SOURCES).forEach((item, index) => {
nextSlots[index] = normalizeSource(item);
});
if (slotIndex < 0 || slotIndex >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) {
return nextSlots;
}
const normalizedSource = normalizeSource(source);
if (normalizedSource) {
nextSlots.forEach((item, index) => {
if (index !== slotIndex && item?.imageUrl === normalizedSource.imageUrl) {
nextSlots[index] = null;
}
});
}
nextSlots[slotIndex] = normalizedSource;
return nextSlots;
}

View File

@ -6,6 +6,7 @@ export default defineConfig({
/routes\/canvas-chat-helpers\.test\.ts/,
/routes\/agent-main-ai-template-entry\.test\.ts/,
/routes\/canvas-agent-operations\.test\.ts/,
/routes\/canvas-capability-runtime\.test\.ts/,
/routes\/canvas-overlay-density\.test\.ts/,
/routes\/image-message-contracts\.test\.ts/,
/routes\/production-local-fallbacks\.test\.ts/,
@ -13,7 +14,7 @@ export default defineConfig({
/routes\/settings-visual-consistency\.test\.ts/,
/routes\/canvas-scale-adjust-intent\.test\.ts/,
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/canvas-psd-template-entry\.test\.ts/,
/routes\/canvas-template-replace-entry\.test\.ts/,
/routes\/model-classification\.test\.ts/,
/routes\/model3d-style-pro-regressions\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,

View File

@ -38,8 +38,13 @@ expectIncludes(
);
expectIncludes(
helpersConfig,
"/routes\\/canvas-psd-template-entry\\.test\\.ts/",
"canvas PSD template regression target"
"/routes\\/canvas-template-replace-entry\\.test\\.ts/",
"canvas template replacement regression target"
);
expectIncludes(
helpersConfig,
"/routes\\/canvas-capability-runtime\\.test\\.ts/",
"canvas capability runtime regression target"
);
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");

View File

@ -22,7 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
"textEdit",
"boxCutout",
"localEdit",
"psdTemplate",
"templateReplace",
"removeWatermark",
"resize",
"multiAngle",

View File

@ -47,6 +47,7 @@ import {
import {
defaultCanvasToolbarConfig,
getCanvasToolbarItem,
normalizeCanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
@ -4875,11 +4876,11 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("keeps the PSD template replacement action enabled by default", () => {
test("keeps the canvas template replacement action enabled by default", () => {
expect(
getCanvasToolbarItem(
defaultCanvasToolbarConfig,
"imagePsdTemplateReplace"
"imageTemplateReplace"
)
).toMatchObject({
label: "模版替换",
@ -4887,6 +4888,24 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("migrates the legacy PSD toolbar setting into canvas template replacement", () => {
const normalized = normalizeCanvasToolbarConfig({
items: {
imagePsdTemplateReplace: {
enabled: false,
label: "旧模版入口",
iconUrl: "https://example.com/template-icon.png",
},
},
});
expect(normalized.items.imageTemplateReplace).toEqual({
enabled: false,
label: "旧模版入口",
iconUrl: "https://example.com/template-icon.png",
});
});
test("syncs image notes into structured canvas annotations", () => {
const first = syncCanvasNodeNoteAnnotationMetadata({
metadata: {

View File

@ -1,26 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("canvas PSD template replacement entry", () => {
test("keeps the selected-image toolbar action and launch flow wired", () => {
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
expect(imageNode).toContain(
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imagePsdTemplateReplace"'
);
expect(imageNode).toContain('isSelectedImageActionInline("psdTemplate")');
expect(imageNode).toContain('isSelectedImageActionInMore("psdTemplate")');
expect(imageNode).toContain('handleAction("psd-template")');
expect(imageNode).toContain("handleOpenPsdTemplateReplace");
expect(imageNode).toContain("/psd-template?launch=");
expect(workspace).toContain(
'requestImageNodeAction(selectedImageNodes[0].id, "psd-template")'
);
});
});

View File

@ -0,0 +1,73 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import {
createCanvasTemplateReplaceSlots,
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
replaceCanvasTemplateSourceSlot,
type CanvasTemplateReplaceSource,
} from "@/lib/canvas/template-replace-batch";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
function source(id: string): CanvasTemplateReplaceSource {
return {
id,
imageUrl: `https://example.com/${id}.png`,
title: id,
origin: "canvas",
sourceNodeId: id,
};
}
test.describe("canvas template replacement entry", () => {
test("keeps the selected-image toolbar action wired to the in-canvas batch dialog", () => {
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
const dialog = readSource("components/canvas/CanvasTemplateReplaceDialog.tsx");
expect(imageNode).toContain(
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageTemplateReplace"'
);
expect(imageNode).toContain('isSelectedImageActionInline("templateReplace")');
expect(imageNode).toContain('isSelectedImageActionInMore("templateReplace")');
expect(imageNode).toContain('handleAction("template-replace")');
expect(imageNode).toContain("<CanvasTemplateReplaceDialog");
expect(imageNode).not.toContain("/psd-template?launch=");
expect(imageNode).not.toContain("/api/psd-templates?summary=1");
expect(workspace).toContain(
'requestImageNodeAction(selectedImageNodes[0].id, "template-replace")'
);
expect(dialog).toContain("MAX_CANVAS_TEMPLATE_REPLACE_SOURCES");
expect(dialog).toContain("imageGenerationService.layoutReplace");
expect(dialog).toContain("for (const resultId of orderedIds)");
expect(dialog).toContain('return "等待上一张"');
expect(dialog).toContain("排队中(前方");
expect(dialog).toContain("全部添加到画布");
expect(dialog).toContain("canvas-template-replace:");
expect(dialog).toContain("max-h-[calc(100dvh-1rem)]");
expect(dialog).toContain("min-h-0 flex-1 overflow-y-auto");
expect(dialog).toContain("shrink-0 gap-2 border-t");
expect(dialog).not.toContain("PSD");
});
test("keeps five fixed slots and deduplicates a source moved between slots", () => {
const slots = createCanvasTemplateReplaceSlots([
source("a"),
source("b"),
source("c"),
source("d"),
source("e"),
source("f"),
]);
expect(slots).toHaveLength(MAX_CANVAS_TEMPLATE_REPLACE_SOURCES);
expect(slots.map((item) => item?.id)).toEqual(["a", "b", "c", "d", "e"]);
const moved = replaceCanvasTemplateSourceSlot(slots, 4, source("a"));
expect(moved[0]).toBeNull();
expect(moved[4]?.id).toBe("a");
});
});