feat(canvas): add durable batch template replacement

This commit is contained in:
134 2026-08-14 10:20:01 +08:00
parent 71b3ce56cf
commit 3010e51f72
14 changed files with 2354 additions and 497 deletions

View File

@ -0,0 +1,143 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getCanvasLayoutReplaceBatchActiveTaskId,
getCanvasLayoutReplaceBatchForUser,
markCanvasLayoutReplaceBatchItemsAdded,
retryCanvasLayoutReplaceBatchItem,
} from "@/lib/canvas/layout-replace-batch";
import {
enqueueCanvasImageTask,
ensureCanvasImageTaskActive,
} from "@/lib/canvas/image-task-queue";
function normalizeUuid(value: unknown) {
const id = String(value || "").trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
? id
: "";
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ batchId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const batchId = normalizeUuid((await params).batchId);
if (!batchId) {
return NextResponse.json({ success: false, error: "批次ID无效" }, { status: 400 });
}
let batch = await getCanvasLayoutReplaceBatchForUser({
batchId,
userId: session.user.id,
});
if (!batch) {
return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 });
}
const activeTaskId = getCanvasLayoutReplaceBatchActiveTaskId(batch);
if (activeTaskId) {
await ensureCanvasImageTaskActive({
taskId: activeTaskId,
userId: session.user.id,
});
batch = await getCanvasLayoutReplaceBatchForUser({
batchId,
userId: session.user.id,
});
}
return NextResponse.json({ success: true, batch });
} catch (error) {
console.error("[Canvas Layout Replace Batch][Detail] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询批次失败",
},
{ status: 500 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ batchId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const batchId = normalizeUuid((await params).batchId);
const body = await request.json().catch(() => ({}));
if (body?.action === "retry") {
const itemId = normalizeUuid(body?.itemId);
if (!batchId || !itemId) {
return NextResponse.json(
{ success: false, error: "缺少需要重试的结果" },
{ status: 400 }
);
}
const taskId = await retryCanvasLayoutReplaceBatchItem({
batchId,
itemId,
userId: session.user.id,
});
if (!taskId) {
return NextResponse.json(
{ success: false, error: "该结果当前无法重试" },
{ status: 409 }
);
}
await enqueueCanvasImageTask(taskId);
return NextResponse.json({ success: true, taskId });
}
const items = Array.isArray(body?.items)
? body.items
.map((item: unknown) => {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
return {
itemId: normalizeUuid(record.itemId),
canvasNodeId: String(record.canvasNodeId || "").trim(),
};
})
.filter(
(item: { itemId: string; canvasNodeId: string }) =>
Boolean(item.itemId && item.canvasNodeId)
)
: [];
if (!batchId || items.length === 0) {
return NextResponse.json(
{ success: false, error: "缺少已添加的结果信息" },
{ status: 400 }
);
}
const updated = await markCanvasLayoutReplaceBatchItemsAdded({
batchId,
userId: session.user.id,
items,
});
if (!updated) {
return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("[Canvas Layout Replace Batch][PATCH] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "更新批次失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,201 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
CANVAS_LAYOUT_REPLACE_BATCH_LIMIT,
createCanvasLayoutReplaceBatch,
getCanvasLayoutReplaceBatchActiveTaskId,
getLatestCanvasLayoutReplaceBatchForUser,
type CanvasLayoutReplaceBatchSource,
} from "@/lib/canvas/layout-replace-batch";
import {
enqueueCanvasImageTask,
ensureCanvasImageTaskActive,
} from "@/lib/canvas/image-task-queue";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const maxDuration = 1200;
function normalizeHttpUrl(value: unknown) {
const url = String(value || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function normalizeUuid(value: unknown) {
const id = String(value || "").trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
? id
: "";
}
function normalizeSources(value: unknown): CanvasLayoutReplaceBatchSource[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const sources: CanvasLayoutReplaceBatchSource[] = [];
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
const sourceImageUrl = normalizeHttpUrl(record.sourceImageUrl);
if (!sourceImageUrl || seen.has(sourceImageUrl)) continue;
seen.add(sourceImageUrl);
sources.push({
sourceImageUrl,
sourceKind: record.sourceKind === "upload" ? "upload" : "canvas",
sourceNodeId: String(record.sourceNodeId || "").trim() || undefined,
sourceTitle: String(record.sourceTitle || "").trim().slice(0, 256) || undefined,
});
if (sources.length >= CANVAS_LAYOUT_REPLACE_BATCH_LIMIT) break;
}
return sources;
}
async function getOwnedProject(projectId: string, userId: string) {
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.limit(1);
return project || null;
}
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const projectId = normalizeUuid(searchParams.get("projectId"));
const sourceNodeId = String(searchParams.get("sourceNodeId") || "").trim();
if (!projectId) {
return NextResponse.json(
{ success: false, error: "缺少有效的画布项目" },
{ status: 400 }
);
}
if (!(await getOwnedProject(projectId, session.user.id))) {
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
}
let batch = await getLatestCanvasLayoutReplaceBatchForUser({
projectId,
userId: session.user.id,
sourceNodeId: sourceNodeId || undefined,
});
const activeTaskId = batch
? getCanvasLayoutReplaceBatchActiveTaskId(batch)
: null;
if (activeTaskId) {
await ensureCanvasImageTaskActive({
taskId: activeTaskId,
userId: session.user.id,
});
batch = await getLatestCanvasLayoutReplaceBatchForUser({
projectId,
userId: session.user.id,
sourceNodeId: sourceNodeId || undefined,
});
}
return NextResponse.json({ success: true, batch: batch || null });
} catch (error) {
console.error("[Canvas Layout Replace Batch][GET] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询批次失败",
},
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const projectId = normalizeUuid(body?.projectId);
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
const sources = normalizeSources(body?.sources);
if (!projectId || !(await getOwnedProject(projectId, session.user.id))) {
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
}
if (sources.length === 0) {
return NextResponse.json(
{ success: false, error: "请至少添加一张待处理图片" },
{ status: 400 }
);
}
if (!referenceImageUrl) {
return NextResponse.json(
{ success: false, error: "请上传目标模版图" },
{ status: 400 }
);
}
const pointsConfig = await getPointsSystemConfig();
const perImageCost = getImageToolCreditCost(pointsConfig, "repaint");
const totalCost = perImageCost * sources.length;
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: totalCost,
reason: "image_repaint",
modelId: "canvas-layout-replace-batch",
note: `批量排版替换 ${sources.length} 张`,
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const created = await createCanvasLayoutReplaceBatch({
userId: session.user.id,
projectId,
sources,
referenceImageUrl,
supplementalPrompt:
typeof body?.supplementalPrompt === "string"
? body.supplementalPrompt
: undefined,
imageSize: typeof body?.imageSize === "string" ? body.imageSize : "2K",
model: typeof body?.model === "string" ? body.model : undefined,
});
await enqueueCanvasImageTask(created.firstTaskId);
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `批量排版替换 ${sources.length} 张(${created.batchId})`,
});
} catch (error) {
console.error("[Points] Failed to consume layout batch points:", error);
}
}
return NextResponse.json({
success: true,
batchId: created.batchId,
firstTaskId: created.firstTaskId,
sourceCount: sources.length,
pointsCost: totalCost,
});
} catch (error) {
console.error("[Canvas Layout Replace Batch][POST] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建批次失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,856 @@
"use client";
import {
Check,
CircleAlert,
ImagePlus,
Images,
LayoutTemplate,
Loader2,
Plus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import {
imageGenerationService,
type CanvasLayoutReplaceBatchItemSnapshot,
type CanvasLayoutReplaceBatchSnapshot,
} from "@/services/canvas";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, ImageNodeData, Size } from "@/types/canvas";
const MAX_BATCH_SOURCES = 5;
const ACTIVE_BATCH_STATUSES = new Set(["pending", "running"]);
type LayoutReplaceSource = {
id: string;
imageUrl: string;
kind: "canvas" | "upload";
nodeId?: string;
title: string;
};
type CanvasLayoutReplaceBatchDialogProps = {
costLabel?: string | null;
onOpenChange: (open: boolean) => void;
open: boolean;
primaryNode: CanvasNode;
};
function isHttpImageUrl(value: unknown) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function getReadyCanvasImage(node: CanvasNode): LayoutReplaceSource | null {
if (node.type !== "image") return null;
const data = node.data as ImageNodeData;
const imageUrl = String(data.src || "").trim();
if (data.status !== "completed" || !isHttpImageUrl(imageUrl)) return null;
return {
id: `canvas:${node.id}`,
imageUrl,
kind: "canvas",
nodeId: node.id,
title: String(data.title || "画布图片").trim() || "画布图片",
};
}
function buildInitialSources(
primaryNode: CanvasNode,
nodes: CanvasNode[],
selectedNodeIds: string[]
) {
const selectedById = new Map(nodes.map((node) => [node.id, node]));
const orderedIds = [primaryNode.id, ...selectedNodeIds.filter((id) => id !== primaryNode.id)];
const sources: LayoutReplaceSource[] = [];
const seen = new Set<string>();
for (const id of orderedIds) {
const source = getReadyCanvasImage(selectedById.get(id) || primaryNode);
if (!source || seen.has(source.imageUrl)) continue;
seen.add(source.imageUrl);
sources.push(source);
if (sources.length >= MAX_BATCH_SOURCES) break;
}
return sources;
}
function sourcesFromBatch(batch: CanvasLayoutReplaceBatchSnapshot) {
return batch.items.map((item) => ({
id: item.sourceNodeId ? `canvas:${item.sourceNodeId}` : `upload:${item.id}`,
imageUrl: item.sourceImageUrl,
kind: item.sourceKind === "upload" ? ("upload" as const) : ("canvas" as const),
nodeId: item.sourceNodeId || undefined,
title: item.sourceTitle || `图片 ${item.position}`,
}));
}
function getBatchStatusLabel(status: string) {
if (status === "succeeded") return "全部完成";
if (status === "partial_failed") return "部分失败";
if (status === "failed") return "处理失败";
if (status === "running") return "处理中";
return "排队中";
}
function getItemStatusLabel(item: CanvasLayoutReplaceBatchItemSnapshot) {
if (item.status === "succeeded") return item.addedToCanvas ? "已加入画布" : "生成完成";
if (item.status === "failed") return "生成失败";
if (item.status === "running") return "正在生成";
return "排队中";
}
async function resolveImageSize(url: string, fallback: Size) {
if (typeof window === "undefined") return fallback;
return new Promise<Size>((resolve) => {
const image = new window.Image();
const timeout = window.setTimeout(() => resolve(fallback), 6000);
image.onload = () => {
window.clearTimeout(timeout);
const naturalWidth = image.naturalWidth || fallback.width;
const naturalHeight = image.naturalHeight || fallback.height;
const width = Math.max(180, Math.min(720, fallback.width || 360));
resolve({
width,
height: Math.max(120, Math.min(1000, Math.round((width * naturalHeight) / naturalWidth))),
});
};
image.onerror = () => {
window.clearTimeout(timeout);
resolve(fallback);
};
image.src = buildImageProxyUrl(url, { delivery: "proxy" });
});
}
export function CanvasLayoutReplaceBatchDialog({
costLabel,
onOpenChange,
open,
primaryNode,
}: CanvasLayoutReplaceBatchDialogProps) {
const allNodes = useCanvasStore((state) => state.nodes);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const projectId = useCanvasStore((state) => state.projectId);
const addNode = useCanvasStore((state) => state.addNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const sourceUploadInputRef = useRef<HTMLInputElement>(null);
const referenceUploadInputRef = useRef<HTMLInputElement>(null);
const seenSucceededItemIdsRef = useRef(new Set<string>());
const [sources, setSources] = useState<LayoutReplaceSource[]>([]);
const [referenceImageUrl, setReferenceImageUrl] = useState("");
const [supplementalPrompt, setSupplementalPrompt] = useState("");
const [batch, setBatch] = useState<CanvasLayoutReplaceBatchSnapshot | null>(null);
const [selectedResultIds, setSelectedResultIds] = useState<Set<string>>(new Set());
const [isCanvasPickerOpen, setIsCanvasPickerOpen] = useState(false);
const [isLoadingBatch, setIsLoadingBatch] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUploadingSources, setIsUploadingSources] = useState(false);
const [isUploadingReference, setIsUploadingReference] = useState(false);
const [isAddingResults, setIsAddingResults] = useState(false);
const [retryingItemId, setRetryingItemId] = useState<string | null>(null);
const readyCanvasSources = useMemo(
() => allNodes.map(getReadyCanvasImage).filter(Boolean) as LayoutReplaceSource[],
[allNodes]
);
const sourceUrlSet = useMemo(
() => new Set(sources.map((source) => source.imageUrl)),
[sources]
);
const successfulItems = useMemo(
() => batch?.items.filter((item) => item.status === "succeeded" && item.resultImageUrl) || [],
[batch]
);
const batchIsActive = Boolean(batch && ACTIVE_BATCH_STATUSES.has(batch.status));
const terminalItemCount = batch
? batch.items.filter((item) => item.status === "succeeded" || item.status === "failed").length
: 0;
const batchProgress = batch
? Math.round((terminalItemCount / Math.max(1, batch.totalCount)) * 100)
: 0;
const applyBatchSnapshot = useCallback((nextBatch: CanvasLayoutReplaceBatchSnapshot) => {
setBatch(nextBatch);
setSources(sourcesFromBatch(nextBatch));
setReferenceImageUrl(nextBatch.referenceImageUrl);
setSupplementalPrompt(nextBatch.supplementalPrompt || "");
const newlySucceeded = nextBatch.items
.filter(
(item) =>
item.status === "succeeded" &&
!item.addedToCanvas &&
!seenSucceededItemIdsRef.current.has(item.id)
)
.map((item) => item.id);
if (newlySucceeded.length > 0) {
setSelectedResultIds((current) => new Set([...current, ...newlySucceeded]));
}
nextBatch.items
.filter((item) => item.status === "succeeded")
.forEach((item) => seenSucceededItemIdsRef.current.add(item.id));
}, []);
const loadBatch = useCallback(
async (batchId: string) => {
const result = await imageGenerationService.getLayoutReplaceBatch(batchId);
if (result.success && result.data?.batch) {
applyBatchSnapshot(result.data.batch);
return result.data.batch;
}
return null;
},
[applyBatchSnapshot]
);
useEffect(() => {
if (!open) return;
const initialSources = buildInitialSources(primaryNode, allNodes, selectedNodeIds);
setSources(initialSources);
setSelectedResultIds(new Set());
seenSucceededItemIdsRef.current = new Set();
if (!projectId) {
setBatch(null);
return;
}
let cancelled = false;
setIsLoadingBatch(true);
void imageGenerationService
.getLatestLayoutReplaceBatch({ projectId, sourceNodeId: primaryNode.id })
.then((result) => {
if (cancelled) return;
if (result.success && result.data?.batch) {
applyBatchSnapshot(result.data.batch);
} else {
setBatch(null);
}
})
.finally(() => {
if (!cancelled) setIsLoadingBatch(false);
});
return () => {
cancelled = true;
};
}, [allNodes, applyBatchSnapshot, open, primaryNode, projectId, selectedNodeIds]);
useEffect(() => {
if (!open || !batch?.id || !batchIsActive) return;
const interval = window.setInterval(() => {
void loadBatch(batch.id);
}, 2500);
return () => window.clearInterval(interval);
}, [batch?.id, batchIsActive, loadBatch, open]);
const startNewBatchDraft = useCallback(() => {
setBatch(null);
setSelectedResultIds(new Set());
seenSucceededItemIdsRef.current = new Set();
setSources(buildInitialSources(primaryNode, allNodes, selectedNodeIds));
}, [allNodes, primaryNode, selectedNodeIds]);
const addCanvasSource = useCallback((source: LayoutReplaceSource) => {
setSources((current) => {
if (current.some((item) => item.imageUrl === source.imageUrl)) return current;
if (current.length >= MAX_BATCH_SOURCES) {
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
return current;
}
return [...current, source];
});
}, []);
const removeSource = useCallback((sourceId: string) => {
setSources((current) => current.filter((source) => source.id !== sourceId));
}, []);
const handleSourceUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.currentTarget.files || []).filter((file) =>
file.type.startsWith("image/")
);
event.currentTarget.value = "";
const remaining = Math.max(0, MAX_BATCH_SOURCES - sources.length);
if (remaining === 0 || files.length === 0) return;
const selectedFiles = files.slice(0, remaining);
if (selectedFiles.length < files.length) {
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
}
setIsUploadingSources(true);
try {
const uploaded: LayoutReplaceSource[] = [];
for (const file of selectedFiles) {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || `${file.name} 上传失败`);
}
uploaded.push({
id: `upload:${crypto.randomUUID()}`,
imageUrl: result.data.url,
kind: "upload",
title: file.name.replace(/\.[^.]+$/, "") || "上传图片",
});
}
setSources((current) => {
const next = [...current];
for (const source of uploaded) {
if (!next.some((item) => item.imageUrl === source.imageUrl)) next.push(source);
}
return next.slice(0, MAX_BATCH_SOURCES);
});
toast.success(`已上传 ${uploaded.length} 张图片`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片上传失败");
} finally {
setIsUploadingSources(false);
}
},
[sources.length]
);
const handleReferenceUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file || !file.type.startsWith("image/")) return;
setIsUploadingReference(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || "目标模版图上传失败");
}
setReferenceImageUrl(result.data.url);
toast.success("目标模版图已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "目标模版图上传失败");
} finally {
setIsUploadingReference(false);
}
}, []);
const handleSubmit = useCallback(async () => {
if (!projectId) {
toast.error("当前画布尚未保存,无法创建批次");
return;
}
if (sources.length === 0) {
toast.error("请至少添加一张待处理图片");
return;
}
if (!isHttpImageUrl(referenceImageUrl)) {
toast.error("请上传目标模版图");
return;
}
if (isSubmitting || isUploadingSources || isUploadingReference) return;
setIsSubmitting(true);
try {
const result = await imageGenerationService.createLayoutReplaceBatch({
projectId,
sources: sources.map((source) => ({
sourceImageUrl: source.imageUrl,
sourceKind: source.kind,
sourceNodeId: source.nodeId,
sourceTitle: source.title,
})),
referenceImageUrl,
supplementalPrompt,
imageSize: "2K",
});
if (!result.success || !result.data?.batchId) {
throw new Error(result.error || "创建批次失败");
}
const nextBatch = await loadBatch(result.data.batchId);
if (!nextBatch) throw new Error("批次已创建,但状态读取失败");
toast.success(`已提交 ${sources.length} 张图片,正在顺序处理`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建批次失败");
} finally {
setIsSubmitting(false);
}
}, [
isSubmitting,
isUploadingReference,
isUploadingSources,
loadBatch,
projectId,
referenceImageUrl,
sources,
supplementalPrompt,
]);
const addItemsToCanvas = useCallback(
async (items: CanvasLayoutReplaceBatchItemSnapshot[]) => {
if (!batch || items.length === 0 || isAddingResults) return;
setIsAddingResults(true);
try {
const created: Array<{ itemId: string; canvasNodeId: string }> = [];
for (const [index, item] of items.entries()) {
if (!item.resultImageUrl || item.addedToCanvas) continue;
const sourceNode = allNodes.find((candidate) => candidate.id === item.sourceNodeId);
const anchor = sourceNode || primaryNode;
const resultSize = await resolveImageSize(item.resultImageUrl, anchor.size);
const canvasNodeId = addNode({
type: "image",
position: {
x: anchor.position.x + anchor.size.width + 20,
y: anchor.position.y + index * 24,
},
size: resultSize,
data: {
src: item.resultImageUrl,
thumbnailUrl: item.resultThumbnailUrl || item.resultImageUrl,
status: "completed",
prompt: "批量排版替换结果",
title: `${item.sourceTitle || `图片 ${item.position}`}-排版替换`,
originalSrc: item.sourceImageUrl,
model: item.resultModelId || undefined,
metadata: {
editMode: "layout-replace",
batchId: batch.id,
batchItemId: item.id,
taskId: item.taskId,
sourceNodeId: item.sourceNodeId,
layoutReferenceUrl: batch.referenceImageUrl,
layoutReplaceCompletedAt: new Date().toISOString(),
},
},
connections: item.sourceNodeId ? [item.sourceNodeId] : [],
});
created.push({ itemId: item.id, canvasNodeId });
}
if (created.length === 0) return;
selectNodes(created.map((item) => item.canvasNodeId));
const marked = await imageGenerationService.markLayoutReplaceBatchItemsAdded({
batchId: batch.id,
items: created,
});
if (!marked.success) {
toast.warning("结果已加入画布,但同步批次状态失败");
}
await loadBatch(batch.id);
setSelectedResultIds((current) => {
const next = new Set(current);
created.forEach((item) => next.delete(item.itemId));
return next;
});
toast.success(`已将 ${created.length} 张结果加入画布`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "添加到画布失败");
} finally {
setIsAddingResults(false);
}
},
[addNode, allNodes, batch, isAddingResults, loadBatch, primaryNode, selectNodes]
);
const selectedSuccessfulItems = successfulItems.filter(
(item) => selectedResultIds.has(item.id) && !item.addedToCanvas
);
const retryItem = useCallback(
async (item: CanvasLayoutReplaceBatchItemSnapshot) => {
if (!batch || batchIsActive || retryingItemId) return;
setRetryingItemId(item.id);
try {
const result = await imageGenerationService.retryLayoutReplaceBatchItem({
batchId: batch.id,
itemId: item.id,
});
if (!result.success) throw new Error(result.error || "重试失败");
await loadBatch(batch.id);
toast.success("已重新加入生成队列");
} catch (error) {
toast.error(error instanceof Error ? error.message : "重试失败");
} finally {
setRetryingItemId(null);
}
},
[batch, batchIsActive, loadBatch, retryingItemId]
);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1180px] 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 flex-wrap items-center justify-between gap-3">
<DialogTitle className="flex items-center gap-2 text-base">
<LayoutTemplate className="h-4 w-4" />
AI 模版替换
</DialogTitle>
{batch ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{getBatchStatusLabel(batch.status)}</span>
<span>{terminalItemCount}/{batch.totalCount}</span>
</div>
) : null}
</div>
<DialogDescription className="sr-only">
最多选择五张图片,按照目标模版顺序生成并添加到画布
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
{isLoadingBatch ? (
<div className="flex min-h-[360px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>待处理图片 {sources.length}/{MAX_BATCH_SOURCES}</Label>
{!batch ? (
<div className="flex items-center gap-2">
<Popover onOpenChange={setIsCanvasPickerOpen} open={isCanvasPickerOpen}>
<PopoverTrigger asChild>
<Button disabled={sources.length >= MAX_BATCH_SOURCES} size="sm" type="button" variant="outline">
<Images className="mr-1.5 h-4 w-4" />
从画布添加
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(520px,calc(100vw-2rem))] p-3">
<div className="grid max-h-[320px] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4">
{readyCanvasSources.map((source) => {
const selected = sourceUrlSet.has(source.imageUrl);
return (
<button
className={cn(
"relative aspect-square overflow-hidden rounded-md border bg-muted/30",
selected ? "border-primary ring-1 ring-primary" : "hover:border-foreground/30"
)}
disabled={selected || sources.length >= MAX_BATCH_SOURCES}
key={source.id}
onClick={() => addCanvasSource(source)}
type="button"
>
<img
alt={source.title}
className="h-full w-full object-cover"
src={buildImageProxyUrl(source.imageUrl, { width: 180 })}
/>
{selected ? (
<span className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</span>
) : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<input
accept="image/*"
className="hidden"
multiple
onChange={(event) => void handleSourceUpload(event)}
ref={sourceUploadInputRef}
type="file"
/>
<Button
disabled={isUploadingSources || sources.length >= MAX_BATCH_SOURCES}
onClick={() => sourceUploadInputRef.current?.click()}
size="sm"
type="button"
variant="outline"
>
{isUploadingSources ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1.5 h-4 w-4" />
)}
上传图片
</Button>
</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: MAX_BATCH_SOURCES }, (_, index) => {
const source = sources[index];
return (
<div
className="relative aspect-square min-w-0 overflow-hidden rounded-md border bg-muted/25"
key={source?.id || `empty-${index}`}
>
<span className="absolute left-1.5 top-1.5 z-10 flex h-5 min-w-5 items-center justify-center rounded bg-black/65 px-1 text-[10px] text-white">
{index + 1}
</span>
{source ? (
<>
<img
alt={source.title}
className="h-full w-full object-cover"
src={buildImageProxyUrl(source.imageUrl, { width: 240 })}
/>
{!batch && index > 0 ? (
<button
aria-label={`移除第 ${index + 1} 张图片`}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/65 text-white hover:bg-black/80"
onClick={() => removeSource(source.id)}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
{source.title}
</div>
</>
) : (
<button
aria-label={`添加第 ${index + 1} 张图片`}
className="flex h-full w-full items-center justify-center text-muted-foreground hover:bg-muted/50"
disabled={Boolean(batch)}
onClick={() => sourceUploadInputRef.current?.click()}
type="button"
>
<Plus className="h-5 w-5" />
</button>
)}
</div>
);
})}
</div>
<div className="space-y-2">
<Label htmlFor={`layout-batch-prompt-${primaryNode.id}`}>补充要求</Label>
<Textarea
className="min-h-[88px] max-h-[180px]"
disabled={Boolean(batch)}
id={`layout-batch-prompt-${primaryNode.id}`}
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
placeholder="可选,只写产品替换细节"
value={supplementalPrompt}
/>
</div>
</section>
<section className="min-w-0 space-y-2">
<Label htmlFor={`layout-batch-reference-${primaryNode.id}`}>目标模版图</Label>
<Input
disabled={Boolean(batch)}
id={`layout-batch-reference-${primaryNode.id}`}
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
placeholder="https://..."
value={referenceImageUrl}
/>
<input
accept="image/*"
className="hidden"
onChange={(event) => void handleReferenceUpload(event)}
ref={referenceUploadInputRef}
type="file"
/>
<button
className="flex h-[clamp(180px,34dvh,360px)] w-full items-center justify-center overflow-hidden rounded-md border border-dashed bg-muted/20"
disabled={Boolean(batch) || isUploadingReference}
onClick={() => referenceUploadInputRef.current?.click()}
type="button"
>
{isUploadingReference ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : isHttpImageUrl(referenceImageUrl) ? (
<img
alt="目标模版图"
className="h-full w-full object-contain"
src={buildImageProxyUrl(referenceImageUrl, { width: 720 })}
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImagePlus className="h-6 w-6" />
<span className="text-xs">上传目标模版图</span>
</div>
)}
</button>
</section>
</div>
{batch ? (
<section className="space-y-3 border-t pt-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<Label>生成结果</Label>
<div className="text-xs text-muted-foreground">
{getBatchStatusLabel(batch.status)} · {terminalItemCount}/{batch.totalCount}
</div>
</div>
{!batchIsActive ? (
<Button onClick={startNewBatchDraft} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
新建批次
</Button>
) : null}
</div>
<Progress className="h-1.5" value={batchProgress} />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{batch.items.map((item) => {
const canAdd = item.status === "succeeded" && Boolean(item.resultImageUrl) && !item.addedToCanvas;
const checked = selectedResultIds.has(item.id);
return (
<div className="overflow-hidden rounded-md border" key={item.id}>
<div className="grid grid-cols-2 gap-px bg-border">
<div className="aspect-square bg-muted">
<img
alt={item.sourceTitle || `图片 ${item.position}`}
className="h-full w-full object-cover"
src={buildImageProxyUrl(item.sourceImageUrl, { width: 280 })}
/>
</div>
<div className="relative flex aspect-square items-center justify-center bg-muted/40">
{item.resultImageUrl ? (
<img
alt={`结果 ${item.position}`}
className="h-full w-full object-cover"
src={buildImageProxyUrl(item.resultThumbnailUrl || item.resultImageUrl, { width: 280 })}
/>
) : item.status === "failed" ? (
<CircleAlert className="h-6 w-6 text-destructive" />
) : item.status === "running" ? (
<Loader2 className="h-6 w-6 animate-spin text-primary" />
) : (
<Loader2 className="h-5 w-5 text-muted-foreground" />
)}
{canAdd ? (
<label className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded bg-background/90 shadow-sm">
<input
checked={checked}
className="h-4 w-4"
onChange={(event) => {
setSelectedResultIds((current) => {
const next = new Set(current);
if (event.currentTarget.checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
type="checkbox"
/>
</label>
) : null}
</div>
</div>
<div className="flex min-h-12 items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium">
{item.sourceTitle || `图片 ${item.position}`}
</div>
<div
className={cn(
"text-[11px] text-muted-foreground",
item.status === "failed" && "text-destructive"
)}
>
{getItemStatusLabel(item)}
</div>
</div>
{canAdd ? (
<Button
disabled={isAddingResults}
onClick={() => void addItemsToCanvas([item])}
size="sm"
type="button"
variant="outline"
>
加入画布
</Button>
) : item.status === "failed" && !batchIsActive ? (
<Button
disabled={Boolean(retryingItemId)}
onClick={() => void retryItem(item)}
size="sm"
type="button"
variant="outline"
>
{retryingItemId === item.id ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
重试
</Button>
) : null}
</div>
{item.error ? (
<div className="border-t px-3 py-2 text-xs text-destructive">
{item.error}
</div>
) : null}
</div>
);
})}
</div>
</section>
) : null}
</div>
)}
</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">
关闭
</Button>
{batch ? (
<Button
disabled={selectedSuccessfulItems.length === 0 || isAddingResults}
onClick={() => void addItemsToCanvas(selectedSuccessfulItems)}
type="button"
>
{isAddingResults ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Images className="mr-2 h-4 w-4" />
)}
添加所选到画布 ({selectedSuccessfulItems.length})
</Button>
) : (
<Button
disabled={
sources.length === 0 ||
!isHttpImageUrl(referenceImageUrl) ||
isSubmitting ||
isUploadingReference ||
isUploadingSources
}
onClick={() => void handleSubmit()}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-2 h-4 w-4" />
)}
批量处理 {sources.length} 张
{costLabel ? (
<span className="ml-1 rounded-full bg-white/18 px-1.5 py-0.5 text-[10px] text-white">
{sources.length} × {costLabel}
</span>
) : null}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -2537,7 +2537,7 @@ export function CanvasWorkspace({
</Button>
) : null}
{selectedImageLayoutReplaceEnabled &&
selectedImageNodes.length === 1 ? (
selectedImageNodes.length >= 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
@ -2547,7 +2547,13 @@ 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, "layout-replace")
requestImageNodeAction(selectedImageNodes[0].id, "layout-replace", {
args: {
sourceNodeIds: selectedImageNodes
.slice(0, 5)
.map((selectedNode) => selectedNode.id),
},
})
}
title={selectedImageLayoutReplaceItem.label}
>

View File

@ -48,6 +48,7 @@ import { createPortal } from "react-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayoutReplaceBatchDialog";
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
@ -657,8 +658,6 @@ const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
const AI_WASH_TASK_POLL_LIMIT = 80;
const LAYOUT_REPLACE_TASK_POLL_INTERVAL_MS = 2500;
const LAYOUT_REPLACE_TASK_POLL_LIMIT = 240;
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
@ -804,76 +803,6 @@ async function pollAiWashQuickTask(taskId: string) {
throw new Error("AI洗图任务轮询超时,请稍后重试");
}
async function pollLayoutReplaceTask(taskId: string) {
for (let attempt = 0; attempt < LAYOUT_REPLACE_TASK_POLL_LIMIT; attempt += 1) {
await sleep(LAYOUT_REPLACE_TASK_POLL_INTERVAL_MS);
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
const status = String(task?.status || "").toLowerCase();
if (!taskResult.success || !task) {
continue;
}
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
const imageUrl = image?.originalUrl || image?.thumbnailUrl || "";
if (!imageUrl) {
throw new Error("排版替换已完成但未返回图片");
}
return {
imageUrl,
thumbnailUrl: image?.thumbnailUrl || imageUrl,
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
prompt: String(task.result?.prompt || "").trim(),
};
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "排版替换生成失败");
}
}
throw new Error("排版替换任务等待超时,请稍后重试");
}
async function resolveImageNaturalDimensions(imageUrl: string) {
const source = String(imageUrl || "").trim();
if (!source || typeof window === "undefined") {
throw new Error("参考图地址不可用");
}
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new window.Image();
const cleanup = () => {
window.clearTimeout(timeoutId);
image.onload = null;
image.onerror = null;
};
const timeoutId = window.setTimeout(() => {
cleanup();
reject(new Error("读取参考图尺寸超时"));
}, 8000);
image.onload = () => {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
cleanup();
if (width > 0 && height > 0) {
resolve({ width, height });
return;
}
reject(new Error("参考图尺寸不可用"));
};
image.onerror = () => {
cleanup();
reject(new Error("读取参考图尺寸失败"));
};
image.decoding = "async";
image.src = buildImageProxyUrl(source, { delivery: "proxy" });
});
}
const isTerminal3DFailureStatus = (status?: string | null) =>
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
@ -1765,11 +1694,6 @@ export function ImageNode({
);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [layoutReplaceDialogOpen, setLayoutReplaceDialogOpen] = useState(false);
const [layoutReplaceReferenceUrl, setLayoutReplaceReferenceUrl] = useState("");
const [layoutReplacePrompt, setLayoutReplacePrompt] = useState("");
const [isUploadingLayoutReplaceReference, setIsUploadingLayoutReplaceReference] =
useState(false);
const [isSubmittingLayoutReplace, setIsSubmittingLayoutReplace] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
@ -6154,8 +6078,6 @@ export function ImageNode({
setAiWashQuickPanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setLayoutReplaceReferenceUrl("");
setLayoutReplacePrompt("");
setLayoutReplaceDialogOpen(true);
}, [resetBoxCutoutState, resetLocalEditState]);
@ -6478,248 +6400,6 @@ export function ImageNode({
e.target.value = "";
};
const handleLayoutReplaceReferenceUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
setIsUploadingLayoutReplaceReference(true);
try {
const uploaded = await uploadChatAttachmentBlob(file, file.name);
setLayoutReplaceReferenceUrl(uploaded.url);
toast.success("参考图已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setIsUploadingLayoutReplaceReference(false);
}
},
[]
);
const handleSubmitLayoutReplace = useCallback(async () => {
const sourceUrl = String(data.src || "").trim();
const referenceUrl = layoutReplaceReferenceUrl.trim();
if (!sourceUrl) {
toast.error("请先上传图片");
return;
}
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(sourceUrl)) {
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
return;
}
if (!isHttpImageUrl(referenceUrl)) {
toast.error("请先上传或填写可访问的参考图地址");
return;
}
if (isProcessing || isSubmittingLayoutReplace) {
toast.info("正在处理中,请稍候...");
return;
}
const actionName = "排版替换";
const resultTitle = `${displayTitle}-${actionName}`;
const displayPrompt = `${actionName}处理中...`;
const createdAt = new Date().toISOString();
setLayoutReplaceDialogOpen(false);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
setIsSubmittingLayoutReplace(true);
setAiWashQuickPanelOpen(false);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: displayPrompt,
title: resultTitle,
originalSrc: sourceUrl,
metadata: {
editMode: "layout-replace",
sourceNodeId: node.id,
layoutReferenceUrl: referenceUrl,
layoutReplaceStartedAt: createdAt,
},
},
connections: [node.id],
});
selectNode(newNodeId);
let aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height);
let referenceDimensions: { width: number; height: number } | null = null;
try {
try {
referenceDimensions = await resolveImageNaturalDimensions(referenceUrl);
aspectRatio = guessNearestAspectRatio(
referenceDimensions.width,
referenceDimensions.height
);
const resultSize = fitSizeWithinBox({
maxHeight: Math.max(
320,
Math.min(1800, Math.round(Math.max(node.size.height, node.size.width) * 3.2))
),
maxWidth: Math.max(160, Math.min(900, Math.round(node.size.width))),
minHeight: 120,
minWidth: 120,
sourceHeight: referenceDimensions.height,
sourceWidth: referenceDimensions.width,
});
updateNode(newNodeId, {
size: resultSize,
data: {
src: "",
status: "generating",
prompt: displayPrompt,
title: resultTitle,
originalSrc: sourceUrl,
metadata: {
editMode: "layout-replace",
sourceNodeId: node.id,
layoutReferenceUrl: referenceUrl,
layoutReferenceWidth: referenceDimensions.width,
layoutReferenceHeight: referenceDimensions.height,
layoutAspectRatio: aspectRatio,
layoutReplaceStartedAt: createdAt,
},
},
});
} catch (dimensionError) {
console.warn("[Canvas Layout Replace] 读取参考图尺寸失败:", dimensionError);
}
const submitResult = await imageGenerationService.layoutReplace({
sourceImageUrl: sourceUrl,
referenceImageUrl: referenceUrl,
supplementalPrompt: layoutReplacePrompt,
imageSize: "2K",
sourceNodeId: node.id,
referenceLabel: "画布排版参考图",
debugMeta: {
layoutReferenceUrl: referenceUrl,
layoutReferenceWidth: referenceDimensions?.width,
layoutReferenceHeight: referenceDimensions?.height,
layoutAspectRatio: aspectRatio,
},
});
if (!submitResult.success) {
throw new Error(submitResult.error || "排版替换提交失败");
}
const taskId = submitResult.data?.taskId;
if (!taskId) {
throw new Error("排版替换未返回任务ID");
}
updateNode(newNodeId, {
data: {
src: "",
status: "generating",
prompt: displayPrompt,
title: resultTitle,
originalSrc: sourceUrl,
metadata: {
editMode: "layout-replace",
sourceNodeId: node.id,
taskId,
layoutReferenceUrl: referenceUrl,
layoutReferenceWidth: referenceDimensions?.width,
layoutReferenceHeight: referenceDimensions?.height,
layoutAspectRatio: aspectRatio,
layoutReplaceStartedAt: createdAt,
},
},
});
const completed = await pollLayoutReplaceTask(taskId);
if (!completed?.imageUrl) {
throw new Error("排版替换未返回结果图片");
}
updateNode(newNodeId, {
data: {
src: completed.imageUrl,
status: "completed",
prompt: `${actionName}结果`,
title: resultTitle,
originalSrc: sourceUrl,
model: completed.modelId || undefined,
metadata: {
editMode: "layout-replace",
sourceNodeId: node.id,
taskId,
thumbnailUrl: completed.thumbnailUrl || completed.imageUrl,
layoutReferenceUrl: referenceUrl,
layoutReferenceWidth: referenceDimensions?.width,
layoutReferenceHeight: referenceDimensions?.height,
layoutAspectRatio: aspectRatio,
normalizedImageSize: referenceDimensions
? `${referenceDimensions.width}x${referenceDimensions.height}`
: undefined,
layoutReplaceStartedAt: createdAt,
layoutReplaceCompletedAt: new Date().toISOString(),
},
},
});
toast.success("排版替换结果已生成到画布");
} catch (error) {
const message = error instanceof Error ? error.message : "排版替换失败";
console.error("[Canvas Layout Replace] failed:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${resultTitle}-失败`,
originalSrc: sourceUrl,
metadata: {
editMode: "layout-replace",
sourceNodeId: node.id,
error: message,
layoutReferenceUrl: referenceUrl,
layoutReferenceWidth: referenceDimensions?.width,
layoutReferenceHeight: referenceDimensions?.height,
layoutAspectRatio: aspectRatio,
layoutReplaceStartedAt: createdAt,
},
},
});
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
setIsSubmittingLayoutReplace(false);
}
}, [
addNode,
data.src,
displayTitle,
isProcessing,
isSubmittingLayoutReplace,
layoutReplacePrompt,
layoutReplaceReferenceUrl,
node,
selectNode,
updateNode,
]);
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
@ -9956,15 +9636,11 @@ export function ImageNode({
<DropdownMenuItem
onClick={() => handleAction("layout-replace")}
>
{isSubmittingLayoutReplace ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
/>
)}
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
/>
<span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
@ -10120,128 +9796,14 @@ export function ImageNode({
prompt={regeneratePrompt}
/>
<Dialog
onOpenChange={(open) => {
if (!isSubmittingLayoutReplace) {
setLayoutReplaceDialogOpen(open);
}
}}
open={layoutReplaceDialogOpen}
>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[760px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-[760px]">
<DialogHeader className="shrink-0 border-b border-slate-200 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4 dark:border-white/10">
<DialogTitle className="flex items-center gap-2 text-base">
<LayoutTemplate className="h-4 w-4" />
排版替换
</DialogTitle>
<DialogDescription className="sr-only">
选择版式参考图,保留参考图文字图标排版并仅替换产品主体
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="min-w-0 space-y-2">
<Label>当前图片</Label>
<div className="flex h-[clamp(160px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-zinc-900">
{data.src ? (
<img
alt={displayTitle}
className="max-h-full max-w-full object-contain"
draggable={false}
src={buildImageProxyUrl(data.src, { width: 560 })}
/>
) : null}
</div>
</div>
<div className="min-w-0 space-y-2">
<Label htmlFor={`layout-replace-reference-url-${node.id}`}>
参考图
</Label>
<Input
id={`layout-replace-reference-url-${node.id}`}
onChange={(event) =>
setLayoutReplaceReferenceUrl(event.currentTarget.value)
}
placeholder="https://..."
value={layoutReplaceReferenceUrl}
/>
<Input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
disabled={isUploadingLayoutReplaceReference}
onChange={(event) => {
void handleLayoutReplaceReferenceUpload(event);
}}
type="file"
/>
<div className="flex h-[clamp(160px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-zinc-900">
{isUploadingLayoutReplaceReference ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : isHttpImageUrl(layoutReplaceReferenceUrl.trim()) ? (
<img
alt="排版参考图"
className="max-h-full max-w-full object-contain"
draggable={false}
src={buildImageProxyUrl(layoutReplaceReferenceUrl.trim(), {
width: 560,
})}
/>
) : (
<LayoutTemplate className="h-6 w-6 text-slate-300" />
)}
</div>
</div>
</div>
<div className="mt-4 space-y-2">
<Label htmlFor={`layout-replace-prompt-${node.id}`}>补充要求</Label>
<Textarea
className="min-h-[80px] max-h-[180px]"
id={`layout-replace-prompt-${node.id}`}
onChange={(event) =>
setLayoutReplacePrompt(event.currentTarget.value)
}
placeholder="可选,只写产品替换细节;不会改写参考图文字、图标和排版"
value={layoutReplacePrompt}
/>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-slate-200 bg-background px-4 py-3 sm:px-6 dark:border-white/10">
<Button
disabled={isSubmittingLayoutReplace}
onClick={() => setLayoutReplaceDialogOpen(false)}
type="button"
variant="outline"
>
取消
</Button>
<Button
disabled={
isProcessing ||
isSubmittingLayoutReplace ||
isUploadingLayoutReplaceReference ||
!isHttpImageUrl(layoutReplaceReferenceUrl.trim())
}
onClick={() => void handleSubmitLayoutReplace()}
type="button"
>
{isSubmittingLayoutReplace ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-2 h-4 w-4" />
)}
开始生成
<ImageActionCreditBadge
className="ml-1 bg-white/18 text-white"
compact
label={localEditCostShortLabel}
/>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{layoutReplaceDialogOpen ? (
<CanvasLayoutReplaceBatchDialog
costLabel={localEditCostShortLabel}
onOpenChange={setLayoutReplaceDialogOpen}
open
primaryNode={node}
/>
) : null}
<ThreeDGenerationDialog
costLabel={
@ -10824,20 +10386,16 @@ export function ImageNode({
{isSelectedImageActionInline("layoutReplace") ? (
<Button
className="group relative 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 || isSubmittingLayoutReplace}
disabled={isProcessing}
onClick={() => handleAction("layout-replace")}
title={imageLayoutReplaceItem.label}
variant="ghost"
>
{isSubmittingLayoutReplace ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
/>
)}
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
/>
{imageLayoutReplaceItem.label}
<ImageActionCreditBadge
compact
@ -11124,15 +10682,11 @@ export function ImageNode({
<DropdownMenuItem
onClick={() => handleAction("layout-replace")}
>
{isSubmittingLayoutReplace ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
/>
)}
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
/>
<span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>

View File

@ -26,9 +26,18 @@ import {
isCanvasLayoutReplaceTaskRequest,
type CanvasLayoutReplaceTaskRequest,
} from "@/lib/canvas/layout-replace-task";
import {
completeCanvasLayoutReplaceBatchItem,
markCanvasLayoutReplaceBatchItemRunning,
} from "@/lib/canvas/layout-replace-batch";
import { persistAgentProjectGeneratedResult } from "@/lib/agents/agent-project-result-persistence";
type CanvasImageTaskStatus = "pending" | "running" | "succeeded" | "failed";
type CanvasImageTaskStatus =
| "waiting"
| "pending"
| "running"
| "succeeded"
| "failed";
type CanvasImageTaskRequest =
| CanvasGenerateImageRequest
| StyleProTaskRequest
@ -449,6 +458,9 @@ async function markStaleDatabaseRunningTasksFailed() {
}
async function runSingleTask(entry: PendingTaskEntry) {
let completedBatchRequest: CanvasLayoutReplaceTaskRequest | null = null;
let completedBatchResult: CanvasImageTaskResult | null = null;
let completedBatchError: string | null = null;
try {
const [task] = await db
.update(canvasImageTask)
@ -472,6 +484,18 @@ async function runSingleTask(entry: PendingTaskEntry) {
}
const request = task.request as CanvasImageTaskRequest;
if (isCanvasLayoutReplaceTaskRequest(request) && request.batchId) {
completedBatchRequest = request;
try {
await markCanvasLayoutReplaceBatchItemRunning(request);
} catch (error) {
console.error("[Canvas Image Task Queue] Failed to mark batch item running:", {
batchId: request.batchId,
error: error instanceof Error ? error.message : String(error),
taskId: entry.taskId,
});
}
}
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
const execution: Promise<CanvasImageTaskResult> = isStyleProTaskRequest(request)
? executeStyleProTask({
@ -502,6 +526,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
runningTimeoutMs,
entry.taskId
);
completedBatchResult = result;
if (isCanvasLayoutReplaceTaskRequest(request) && request.agentProjectId) {
const layoutReplaceResult = result as CanvasGenerateImageResult;
const generatedImageUrl = String(
@ -537,6 +562,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
);
} catch (error) {
completedBatchError = error instanceof Error ? error.message : String(error);
await db
.update(canvasImageTask)
.set({
@ -550,12 +576,31 @@ async function runSingleTask(entry: PendingTaskEntry) {
);
} finally {
const modelKey = releaseRunningTask(entry.taskId);
let nextBatchTaskId: string | null = null;
if (completedBatchRequest?.batchId) {
try {
nextBatchTaskId = await completeCanvasLayoutReplaceBatchItem({
request: completedBatchRequest,
result: completedBatchResult as CanvasGenerateImageResult | null,
error: completedBatchError,
});
} catch (error) {
console.error("[Canvas Image Task Queue] Failed to advance layout batch:", {
batchId: completedBatchRequest.batchId,
error: error instanceof Error ? error.message : String(error),
taskId: entry.taskId,
});
}
}
if (modelKey) {
const batchDispatchIntervalMs = await getCanvasImageBatchDispatchIntervalMs();
if (getQueueState().awaitingNextBatch.has(modelKey)) {
scheduleModelNextBatch(modelKey, batchDispatchIntervalMs);
}
}
if (nextBatchTaskId) {
await enqueueLocalTask(nextBatchTaskId);
}
void drainCanvasImageTaskQueue();
}
}
@ -585,6 +630,71 @@ export async function enqueueCanvasImageTask(taskId: string) {
await drainCanvasImageTaskQueue();
}
export async function ensureCanvasImageTaskActive(params: {
taskId: string;
userId: string;
}) {
const [task] = await db
.select({
id: canvasImageTask.id,
status: canvasImageTask.status,
request: canvasImageTask.request,
result: canvasImageTask.result,
error: canvasImageTask.error,
})
.from(canvasImageTask)
.where(
and(
eq(canvasImageTask.id, params.taskId),
eq(canvasImageTask.userId, params.userId)
)
)
.limit(1);
if (!task || task.status === "waiting") return false;
const request = task.request as CanvasImageTaskRequest;
if (
(task.status === "succeeded" || task.status === "failed") &&
isCanvasLayoutReplaceTaskRequest(request) &&
request.batchId
) {
const nextTaskId = await completeCanvasLayoutReplaceBatchItem({
request,
result: task.result as CanvasGenerateImageResult | null,
error: task.status === "failed" ? task.error || "排版替换失败" : null,
});
if (nextTaskId) {
await enqueueLocalTask(nextTaskId);
await drainCanvasImageTaskQueue();
}
return true;
}
if (task.status === "running" && !getQueueState().running.has(task.id)) {
await db
.update(canvasImageTask)
.set({
status: "pending",
startedAt: null,
completedAt: null,
error: null,
updatedAt: new Date(),
})
.where(
and(
eq(canvasImageTask.id, task.id),
eq(canvasImageTask.status, "running")
)
);
} else if (task.status !== "pending" && task.status !== "running") {
return false;
}
await enqueueLocalTask(task.id, { userId: params.userId });
await drainCanvasImageTaskQueue();
return true;
}
export async function createCanvasImageTask(params: {
userId: string;
request: Record<string, unknown>;

View File

@ -0,0 +1,32 @@
export type LayoutReplaceBatchStateItem = {
status: string;
taskId: string;
};
export function deriveLayoutReplaceBatchAdvance(
items: LayoutReplaceBatchStateItem[]
) {
const succeededCount = items.filter((item) => item.status === "succeeded").length;
const failedCount = items.filter((item) => item.status === "failed").length;
const terminalCount = succeededCount + failedCount;
const isComplete = items.length > 0 && terminalCount >= items.length;
const nextTaskId = isComplete
? null
: items.find((item) => item.status === "queued")?.taskId || null;
const status = isComplete
? failedCount === 0
? "succeeded"
: succeededCount === 0
? "failed"
: "partial_failed"
: "running";
return {
failedCount,
isComplete,
nextTaskId,
status,
succeededCount,
terminalCount,
};
}

View File

@ -0,0 +1,534 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import {
canvasImageTask,
canvasLayoutReplaceBatch,
canvasLayoutReplaceBatchItem,
} from "@/lib/db/schema";
import { deriveLayoutReplaceBatchAdvance } from "./layout-replace-batch-state";
import type { CanvasLayoutReplaceTaskRequest } from "./layout-replace-task";
export const CANVAS_LAYOUT_REPLACE_BATCH_LIMIT = 5;
export type CanvasLayoutReplaceBatchSource = {
sourceImageUrl: string;
sourceKind: "canvas" | "upload";
sourceNodeId?: string;
sourceTitle?: string;
};
export type CanvasLayoutReplaceBatchItemSnapshot = {
id: string;
taskId: string;
position: number;
sourceNodeId: string | null;
sourceImageUrl: string;
sourceTitle: string | null;
sourceKind: string;
status: string;
taskStatus: string;
resultImageUrl: string | null;
resultThumbnailUrl: string | null;
resultModelId: string | null;
error: string | null;
addedToCanvas: boolean;
addedCanvasNodeId: string | null;
};
export type CanvasLayoutReplaceBatchSnapshot = {
id: string;
projectId: string;
status: string;
referenceImageUrl: string;
supplementalPrompt: string | null;
imageSize: string;
totalCount: number;
completedCount: number;
failedCount: number;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
items: CanvasLayoutReplaceBatchItemSnapshot[];
};
type LayoutReplaceTaskResult = {
images?: Array<{
originalUrl?: string;
thumbnailUrl?: string;
}>;
model?: string;
modelRef?: string;
};
function normalizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeHttpUrl(value: unknown) {
const url = normalizeText(value, 4096);
return /^https?:\/\//i.test(url) ? url : "";
}
function isTerminalItemStatus(status: string) {
return status === "succeeded" || status === "failed";
}
function getResultFields(result: LayoutReplaceTaskResult | null | undefined) {
const image = result?.images?.[0];
const resultImageUrl = normalizeHttpUrl(image?.originalUrl || image?.thumbnailUrl);
return {
resultImageUrl: resultImageUrl || null,
resultThumbnailUrl:
normalizeHttpUrl(image?.thumbnailUrl || image?.originalUrl) || resultImageUrl || null,
resultModelId: normalizeText(result?.model || result?.modelRef, 160) || null,
};
}
export async function createCanvasLayoutReplaceBatch(params: {
userId: string;
projectId: string;
sources: CanvasLayoutReplaceBatchSource[];
referenceImageUrl: string;
supplementalPrompt?: string;
imageSize?: string;
model?: string;
}) {
const sources = params.sources.slice(0, CANVAS_LAYOUT_REPLACE_BATCH_LIMIT);
if (sources.length === 0) {
throw new Error("请至少添加一张待处理图片");
}
const batchId = randomUUID();
const referenceImageUrl = normalizeHttpUrl(params.referenceImageUrl);
const supplementalPrompt = normalizeText(params.supplementalPrompt, 600);
const imageSize = normalizeText(params.imageSize || "2K", 16) || "2K";
const model = normalizeText(params.model, 160) || undefined;
const now = new Date();
const records = sources.map((source, index) => {
const taskId = randomUUID();
const itemId = randomUUID();
const position = index + 1;
const taskRequest: CanvasLayoutReplaceTaskRequest = {
taskKind: "canvas-layout-replace",
sourceImageUrl: normalizeHttpUrl(source.sourceImageUrl),
referenceImageUrl,
supplementalPrompt: supplementalPrompt || undefined,
model,
imageSize,
sourceNodeId: normalizeText(source.sourceNodeId, 160) || undefined,
referenceLabel: "画布批量排版参考图",
batchId,
batchItemId: itemId,
batchPosition: position,
batchTotal: sources.length,
canvasProjectId: params.projectId,
debugMeta: {
operation: "layout-replace-batch",
batchId,
batchItemId: itemId,
batchPosition: position,
batchTotal: sources.length,
},
};
return {
itemId,
taskId,
position,
source,
taskRequest,
};
});
if (!referenceImageUrl || records.some((record) => !record.taskRequest.sourceImageUrl)) {
throw new Error("图片地址不可用,请重新上传后再试");
}
await db.transaction(async (tx) => {
await tx.insert(canvasLayoutReplaceBatch).values({
id: batchId,
userId: params.userId,
projectId: params.projectId,
status: "pending",
referenceImageUrl,
supplementalPrompt: supplementalPrompt || null,
imageSize,
totalCount: records.length,
completedCount: 0,
failedCount: 0,
createdAt: now,
updatedAt: now,
});
await tx.insert(canvasImageTask).values(
records.map((record, index) => ({
id: record.taskId,
userId: params.userId,
status: index === 0 ? "pending" : "waiting",
request: record.taskRequest as unknown as Record<string, unknown>,
attempts: 0,
createdAt: new Date(now.getTime() + index),
updatedAt: now,
}))
);
await tx.insert(canvasLayoutReplaceBatchItem).values(
records.map((record, index) => ({
id: record.itemId,
batchId,
taskId: record.taskId,
position: record.position,
sourceNodeId: normalizeText(record.source.sourceNodeId, 160) || null,
sourceImageUrl: record.taskRequest.sourceImageUrl,
sourceTitle: normalizeText(record.source.sourceTitle, 256) || null,
sourceKind: record.source.sourceKind === "upload" ? "upload" : "canvas",
status: index === 0 ? "pending" : "queued",
createdAt: new Date(now.getTime() + index),
updatedAt: now,
}))
);
});
return { batchId, firstTaskId: records[0]?.taskId || "" };
}
export async function markCanvasLayoutReplaceBatchItemRunning(
request: CanvasLayoutReplaceTaskRequest
) {
if (!request.batchId || !request.batchItemId) return;
const now = new Date();
await db.transaction(async (tx) => {
await tx
.update(canvasLayoutReplaceBatchItem)
.set({ status: "running", error: null, updatedAt: now })
.where(
and(
eq(canvasLayoutReplaceBatchItem.id, request.batchItemId as string),
eq(canvasLayoutReplaceBatchItem.batchId, request.batchId as string)
)
);
await tx
.update(canvasLayoutReplaceBatch)
.set({ status: "running", completedAt: null, updatedAt: now })
.where(eq(canvasLayoutReplaceBatch.id, request.batchId as string));
});
}
export async function completeCanvasLayoutReplaceBatchItem(params: {
request: CanvasLayoutReplaceTaskRequest;
result?: LayoutReplaceTaskResult | null;
error?: string | null;
}) {
const { request } = params;
if (!request.batchId || !request.batchItemId) return null;
const now = new Date();
const candidateResultFields = getResultFields(params.result);
const failed = Boolean(params.error) || !candidateResultFields.resultImageUrl;
const resultFields = failed ? getResultFields(null) : candidateResultFields;
return db.transaction(async (tx) => {
const [completedItem] = await tx
.update(canvasLayoutReplaceBatchItem)
.set({
status: failed ? "failed" : "succeeded",
...resultFields,
error: failed
? normalizeText(params.error, 4000) || "排版替换未返回结果图片"
: null,
completedAt: now,
updatedAt: now,
})
.where(
and(
eq(canvasLayoutReplaceBatchItem.id, request.batchItemId as string),
eq(canvasLayoutReplaceBatchItem.batchId, request.batchId as string),
inArray(canvasLayoutReplaceBatchItem.status, ["pending", "running"])
)
)
.returning({ id: canvasLayoutReplaceBatchItem.id });
// Completion can be replayed after a process restart. Only the first state
// transition may promote the next item, otherwise one batch could fan out.
if (!completedItem) return null;
const items = await tx
.select({
id: canvasLayoutReplaceBatchItem.id,
taskId: canvasLayoutReplaceBatchItem.taskId,
status: canvasLayoutReplaceBatchItem.status,
position: canvasLayoutReplaceBatchItem.position,
})
.from(canvasLayoutReplaceBatchItem)
.where(eq(canvasLayoutReplaceBatchItem.batchId, request.batchId as string))
.orderBy(asc(canvasLayoutReplaceBatchItem.position));
const advance = deriveLayoutReplaceBatchAdvance(items);
const nextItem = advance.nextTaskId
? items.find((item) => item.taskId === advance.nextTaskId)
: null;
if (nextItem && !advance.isComplete) {
await tx
.update(canvasLayoutReplaceBatchItem)
.set({ status: "pending", updatedAt: now })
.where(eq(canvasLayoutReplaceBatchItem.id, nextItem.id));
await tx
.update(canvasImageTask)
.set({ status: "pending", updatedAt: now })
.where(
and(
eq(canvasImageTask.id, nextItem.taskId),
eq(canvasImageTask.status, "waiting")
)
);
}
await tx
.update(canvasLayoutReplaceBatch)
.set({
status: advance.status,
completedCount: advance.succeededCount,
failedCount: advance.failedCount,
completedAt: advance.isComplete ? now : null,
updatedAt: now,
})
.where(eq(canvasLayoutReplaceBatch.id, request.batchId as string));
return nextItem && !advance.isComplete ? nextItem.taskId : null;
});
}
async function buildBatchSnapshot(batch: typeof canvasLayoutReplaceBatch.$inferSelect) {
const items = await db
.select()
.from(canvasLayoutReplaceBatchItem)
.where(eq(canvasLayoutReplaceBatchItem.batchId, batch.id))
.orderBy(asc(canvasLayoutReplaceBatchItem.position));
const taskIds = items.map((item) => item.taskId);
const tasks = taskIds.length
? await db
.select({
id: canvasImageTask.id,
status: canvasImageTask.status,
error: canvasImageTask.error,
})
.from(canvasImageTask)
.where(inArray(canvasImageTask.id, taskIds))
: [];
const taskById = new Map(tasks.map((task) => [task.id, task]));
return {
id: batch.id,
projectId: batch.projectId,
status: batch.status,
referenceImageUrl: batch.referenceImageUrl,
supplementalPrompt: batch.supplementalPrompt,
imageSize: batch.imageSize,
totalCount: batch.totalCount,
completedCount: batch.completedCount,
failedCount: batch.failedCount,
completedAt: batch.completedAt,
createdAt: batch.createdAt,
updatedAt: batch.updatedAt,
items: items.map((item) => {
const task = taskById.get(item.taskId);
return {
id: item.id,
taskId: item.taskId,
position: item.position,
sourceNodeId: item.sourceNodeId,
sourceImageUrl: item.sourceImageUrl,
sourceTitle: item.sourceTitle,
sourceKind: item.sourceKind,
status: item.status,
taskStatus: task?.status || item.status,
resultImageUrl: item.resultImageUrl,
resultThumbnailUrl: item.resultThumbnailUrl,
resultModelId: item.resultModelId,
error: item.error || task?.error || null,
addedToCanvas: item.addedToCanvas,
addedCanvasNodeId: item.addedCanvasNodeId,
} satisfies CanvasLayoutReplaceBatchItemSnapshot;
}),
} satisfies CanvasLayoutReplaceBatchSnapshot;
}
export async function getCanvasLayoutReplaceBatchForUser(params: {
batchId: string;
userId: string;
}) {
const [batch] = await db
.select()
.from(canvasLayoutReplaceBatch)
.where(
and(
eq(canvasLayoutReplaceBatch.id, params.batchId),
eq(canvasLayoutReplaceBatch.userId, params.userId)
)
)
.limit(1);
return batch ? buildBatchSnapshot(batch) : null;
}
export async function getLatestCanvasLayoutReplaceBatchForUser(params: {
projectId: string;
userId: string;
sourceNodeId?: string;
}) {
const batches = await db
.select()
.from(canvasLayoutReplaceBatch)
.where(
and(
eq(canvasLayoutReplaceBatch.projectId, params.projectId),
eq(canvasLayoutReplaceBatch.userId, params.userId)
)
)
.orderBy(desc(canvasLayoutReplaceBatch.createdAt))
.limit(12);
for (const batch of batches) {
const snapshot = await buildBatchSnapshot(batch);
if (
!params.sourceNodeId ||
snapshot.items.some((item) => item.sourceNodeId === params.sourceNodeId)
) {
return snapshot;
}
}
return null;
}
export async function markCanvasLayoutReplaceBatchItemsAdded(params: {
batchId: string;
userId: string;
items: Array<{ itemId: string; canvasNodeId: string }>;
}) {
const [ownedBatch] = await db
.select({ id: canvasLayoutReplaceBatch.id })
.from(canvasLayoutReplaceBatch)
.where(
and(
eq(canvasLayoutReplaceBatch.id, params.batchId),
eq(canvasLayoutReplaceBatch.userId, params.userId)
)
)
.limit(1);
if (!ownedBatch) return false;
const now = new Date();
await db.transaction(async (tx) => {
for (const item of params.items.slice(0, CANVAS_LAYOUT_REPLACE_BATCH_LIMIT)) {
await tx
.update(canvasLayoutReplaceBatchItem)
.set({
addedToCanvas: true,
addedCanvasNodeId: normalizeText(item.canvasNodeId, 160) || null,
updatedAt: now,
})
.where(
and(
eq(canvasLayoutReplaceBatchItem.id, item.itemId),
eq(canvasLayoutReplaceBatchItem.batchId, params.batchId),
eq(canvasLayoutReplaceBatchItem.status, "succeeded")
)
);
}
});
return true;
}
export async function retryCanvasLayoutReplaceBatchItem(params: {
batchId: string;
itemId: string;
userId: string;
}) {
const [ownedBatch] = await db
.select({
id: canvasLayoutReplaceBatch.id,
status: canvasLayoutReplaceBatch.status,
})
.from(canvasLayoutReplaceBatch)
.where(
and(
eq(canvasLayoutReplaceBatch.id, params.batchId),
eq(canvasLayoutReplaceBatch.userId, params.userId)
)
)
.limit(1);
if (!ownedBatch || !isCanvasLayoutReplaceBatchTerminal(ownedBatch.status)) {
return null;
}
return db.transaction(async (tx) => {
const now = new Date();
const [retriedItem] = await tx
.update(canvasLayoutReplaceBatchItem)
.set({
status: "pending",
resultImageUrl: null,
resultThumbnailUrl: null,
resultModelId: null,
error: null,
completedAt: null,
updatedAt: now,
})
.where(
and(
eq(canvasLayoutReplaceBatchItem.id, params.itemId),
eq(canvasLayoutReplaceBatchItem.batchId, params.batchId),
eq(canvasLayoutReplaceBatchItem.status, "failed")
)
)
.returning({ taskId: canvasLayoutReplaceBatchItem.taskId });
if (!retriedItem) return null;
await tx
.update(canvasImageTask)
.set({
status: "pending",
result: null,
error: null,
startedAt: null,
completedAt: null,
updatedAt: now,
})
.where(eq(canvasImageTask.id, retriedItem.taskId));
const remainingItems = await tx
.select({ status: canvasLayoutReplaceBatchItem.status })
.from(canvasLayoutReplaceBatchItem)
.where(eq(canvasLayoutReplaceBatchItem.batchId, params.batchId));
await tx
.update(canvasLayoutReplaceBatch)
.set({
status: "running",
completedCount: remainingItems.filter((item) => item.status === "succeeded")
.length,
failedCount: remainingItems.filter((item) => item.status === "failed").length,
completedAt: null,
updatedAt: now,
})
.where(eq(canvasLayoutReplaceBatch.id, params.batchId));
return retriedItem.taskId;
});
}
export function getCanvasLayoutReplaceBatchActiveTaskId(
snapshot: CanvasLayoutReplaceBatchSnapshot
) {
if (["succeeded", "failed", "partial_failed"].includes(snapshot.status)) {
return null;
}
const active = snapshot.items.find((item) =>
["pending", "running"].includes(item.status)
);
return active?.taskId || null;
}
export function isCanvasLayoutReplaceBatchTerminal(status: string) {
return ["succeeded", "failed", "partial_failed"].includes(status);
}

View File

@ -1,5 +1,6 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import {
analyzeReplacementExecutionPlan,
buildAgentReplacementPrompt,
@ -10,8 +11,11 @@ import {
resolveReplacementScenePlanningRuntime,
type AgentDetailReplacementStrategy,
} from "@/app/(chat)/api/chat/_lib/detail-replacement";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { db } from "@/lib/db";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { generatedImage } from "@/lib/db/schema";
import {
generateCanvasImage,
type CanvasGenerateImageResult,
@ -29,6 +33,11 @@ export interface CanvasLayoutReplaceTaskRequest {
agentProjectId?: string;
referenceLabel?: string;
debugMeta?: Record<string, unknown>;
batchId?: string;
batchItemId?: string;
batchPosition?: number;
batchTotal?: number;
canvasProjectId?: string;
}
function normalizeHttpUrl(value: unknown) {
@ -61,6 +70,52 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
) as ArrayBuffer;
}
async function saveLayoutReplaceResultToGallery(params: {
request: CanvasLayoutReplaceTaskRequest;
result: CanvasGenerateImageResult;
userId: string;
}) {
const image = params.result.images[0];
const originalUrl = normalizeHttpUrl(image?.originalUrl || image?.thumbnailUrl);
const thumbnailUrl = normalizeHttpUrl(image?.thumbnailUrl || image?.originalUrl);
if (!originalUrl || !thumbnailUrl) return;
const [existing] = await db
.select({ id: generatedImage.id })
.from(generatedImage)
.where(
and(
eq(generatedImage.userId, params.userId),
eq(generatedImage.originalUrl, originalUrl)
)
)
.limit(1);
if (existing) return;
const imageSize = params.request.imageSize || "2K";
const prompt = params.result.prompt || "AI 模版替换结果";
const modelId = params.result.modelRef || params.result.model || "canvas-layout-replace";
await saveGeneratedImages(
`![thumb|||${originalUrl}|||${imageSize}](${thumbnailUrl})`,
params.userId,
null,
null,
prompt,
modelId,
{
sourceImageUrls: [params.request.sourceImageUrl],
relationType: "edited_from",
sourceType: "canvas_layout_replace",
projectId: params.request.canvasProjectId || null,
userPrompt: params.request.supplementalPrompt || "AI 模版替换",
optimizedPrompt: prompt,
requestedImageCount: 1,
imageSize,
skipBilling: true,
}
);
}
function selectLayoutPatchMask(params: {
generated: Buffer;
height: number;
@ -620,7 +675,7 @@ export async function executeCanvasLayoutReplaceTask(params: {
}
}
return {
const result: CanvasGenerateImageResult = {
images: [
{
originalUrl: normalizedImage.originalUrl,
@ -631,4 +686,17 @@ export async function executeCanvasLayoutReplaceTask(params: {
modelRef: generated.modelRef,
prompt: generated.prompt,
};
await saveLayoutReplaceResultToGallery({
request: params.request,
result,
userId: params.userId,
}).catch((error) => {
console.error("[Canvas Layout Replace] Failed to save result to gallery:", {
error: error instanceof Error ? error.message : String(error),
batchId: params.request.batchId,
});
});
return result;
}

View File

@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatch" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"projectId" uuid NOT NULL REFERENCES "CanvasProject"("id") ON DELETE CASCADE,
"status" varchar(24) NOT NULL DEFAULT 'pending',
"referenceImageUrl" text NOT NULL,
"supplementalPrompt" text,
"imageSize" varchar(16) NOT NULL DEFAULT '2K',
"totalCount" integer NOT NULL,
"completedCount" integer NOT NULL DEFAULT 0,
"failedCount" integer NOT NULL DEFAULT 0,
"completedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "CanvasLayoutReplaceBatch_project_created_idx"
ON "CanvasLayoutReplaceBatch"("projectId", "createdAt");
CREATE INDEX IF NOT EXISTS "CanvasLayoutReplaceBatch_user_status_idx"
ON "CanvasLayoutReplaceBatch"("userId", "status");
CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatchItem" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"batchId" uuid NOT NULL REFERENCES "CanvasLayoutReplaceBatch"("id") ON DELETE CASCADE,
"taskId" uuid NOT NULL REFERENCES "CanvasImageTask"("id") ON DELETE CASCADE,
"position" integer NOT NULL,
"sourceNodeId" varchar(160),
"sourceImageUrl" text NOT NULL,
"sourceTitle" varchar(256),
"sourceKind" varchar(16) NOT NULL DEFAULT 'canvas',
"status" varchar(16) NOT NULL DEFAULT 'pending',
"resultImageUrl" text,
"resultThumbnailUrl" text,
"resultModelId" varchar(160),
"error" text,
"addedToCanvas" boolean NOT NULL DEFAULT false,
"addedCanvasNodeId" varchar(160),
"completedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "CanvasLayoutReplaceBatchItem_batch_position_idx"
ON "CanvasLayoutReplaceBatchItem"("batchId", "position");
CREATE INDEX IF NOT EXISTS "CanvasLayoutReplaceBatchItem_task_idx"
ON "CanvasLayoutReplaceBatchItem"("taskId");

View File

@ -589,6 +589,13 @@
"when": 1786176000000,
"tag": "0097_add_vip98k_provider_type",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1786671736000,
"tag": "0098_add_canvas_layout_replace_batches",
"breakpoints": true
}
]
}

View File

@ -976,6 +976,82 @@ export const canvasImageTask = pgTable("CanvasImageTask", {
export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>;
export const canvasLayoutReplaceBatch = pgTable(
"CanvasLayoutReplaceBatch",
{
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
projectId: uuid("projectId")
.notNull()
.references(() => canvasProject.id, { onDelete: "cascade" }),
status: varchar("status", { length: 24 }).notNull().default("pending"),
referenceImageUrl: text("referenceImageUrl").notNull(),
supplementalPrompt: text("supplementalPrompt"),
imageSize: varchar("imageSize", { length: 16 }).notNull().default("2K"),
totalCount: integer("totalCount").notNull(),
completedCount: integer("completedCount").notNull().default(0),
failedCount: integer("failedCount").notNull().default(0),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
},
(table) => ({
projectCreatedIdx: index("CanvasLayoutReplaceBatch_project_created_idx").on(
table.projectId,
table.createdAt
),
userStatusIdx: index("CanvasLayoutReplaceBatch_user_status_idx").on(
table.userId,
table.status
),
})
);
export type CanvasLayoutReplaceBatch = InferSelectModel<
typeof canvasLayoutReplaceBatch
>;
export const canvasLayoutReplaceBatchItem = pgTable(
"CanvasLayoutReplaceBatchItem",
{
id: uuid("id").primaryKey().notNull().defaultRandom(),
batchId: uuid("batchId")
.notNull()
.references(() => canvasLayoutReplaceBatch.id, { onDelete: "cascade" }),
taskId: uuid("taskId")
.notNull()
.references(() => canvasImageTask.id, { onDelete: "cascade" }),
position: integer("position").notNull(),
sourceNodeId: varchar("sourceNodeId", { length: 160 }),
sourceImageUrl: text("sourceImageUrl").notNull(),
sourceTitle: varchar("sourceTitle", { length: 256 }),
sourceKind: varchar("sourceKind", { length: 16 }).notNull().default("canvas"),
status: varchar("status", { length: 16 }).notNull().default("pending"),
resultImageUrl: text("resultImageUrl"),
resultThumbnailUrl: text("resultThumbnailUrl"),
resultModelId: varchar("resultModelId", { length: 160 }),
error: text("error"),
addedToCanvas: boolean("addedToCanvas").notNull().default(false),
addedCanvasNodeId: varchar("addedCanvasNodeId", { length: 160 }),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
},
(table) => ({
batchPositionIdx: index("CanvasLayoutReplaceBatchItem_batch_position_idx").on(
table.batchId,
table.position
),
taskIdx: index("CanvasLayoutReplaceBatchItem_task_idx").on(table.taskId),
})
);
export type CanvasLayoutReplaceBatchItem = InferSelectModel<
typeof canvasLayoutReplaceBatchItem
>;
export const canvasCapabilityTask = pgTable("CanvasCapabilityTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
requestId: varchar("requestId", { length: 180 }).notNull().unique(),

View File

@ -57,6 +57,40 @@ interface ApiResponse<T = any> {
};
}
export interface CanvasLayoutReplaceBatchItemSnapshot {
id: string;
taskId: string;
position: number;
sourceNodeId: string | null;
sourceImageUrl: string;
sourceTitle: string | null;
sourceKind: string;
status: string;
taskStatus: string;
resultImageUrl: string | null;
resultThumbnailUrl: string | null;
resultModelId: string | null;
error: string | null;
addedToCanvas: boolean;
addedCanvasNodeId: string | null;
}
export interface CanvasLayoutReplaceBatchSnapshot {
id: string;
projectId: string;
status: string;
referenceImageUrl: string;
supplementalPrompt: string | null;
imageSize: string;
totalCount: number;
completedCount: number;
failedCount: number;
completedAt: string | null;
createdAt: string;
updatedAt: string;
items: CanvasLayoutReplaceBatchItemSnapshot[];
}
function normalizeRevisionText(value: unknown) {
return String(value || "").trim();
}
@ -447,6 +481,132 @@ export const imageGenerationService = {
}
},
async createLayoutReplaceBatch(params: {
projectId: string;
sources: Array<{
sourceImageUrl: string;
sourceKind: "canvas" | "upload";
sourceNodeId?: string;
sourceTitle?: string;
}>;
referenceImageUrl: string;
supplementalPrompt?: string;
model?: string;
imageSize?: string;
}): Promise<ApiResponse<{ batchId: string; firstTaskId: string; pointsCost?: number }>> {
try {
const response = await fetch("/api/canvas/layout-replace/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await response.json().catch(() => ({}));
if (response.ok && data?.success && data?.batchId) {
return {
success: true,
data: {
batchId: data.batchId,
firstTaskId: data.firstTaskId,
pointsCost: data.pointsCost,
},
};
}
return { success: false, error: data?.error || "创建批量排版替换失败" };
} catch (error) {
logCatch(error, "layout_replace_batch_create");
return { success: false, error: "创建批量排版替换失败" };
}
},
async getLayoutReplaceBatch(batchId: string): Promise<
ApiResponse<{ batch: CanvasLayoutReplaceBatchSnapshot }>
> {
try {
const response = await fetch(
`/api/canvas/layout-replace/batches/${encodeURIComponent(batchId)}`,
{ cache: "no-store" }
);
const data = await response.json().catch(() => ({}));
if (response.ok && data?.success && data?.batch) {
return { success: true, data: { batch: data.batch } };
}
return { success: false, error: data?.error || "查询批次失败" };
} catch (error) {
logCatch(error, "layout_replace_batch_fetch", { batchId });
return { success: false, error: "查询批次失败" };
}
},
async getLatestLayoutReplaceBatch(params: {
projectId: string;
sourceNodeId?: string;
}): Promise<ApiResponse<{ batch: CanvasLayoutReplaceBatchSnapshot | null }>> {
try {
const query = new URLSearchParams({ projectId: params.projectId });
if (params.sourceNodeId) query.set("sourceNodeId", params.sourceNodeId);
const response = await fetch(
`/api/canvas/layout-replace/batches?${query.toString()}`,
{ cache: "no-store" }
);
const data = await response.json().catch(() => ({}));
if (response.ok && data?.success) {
return { success: true, data: { batch: data.batch || null } };
}
return { success: false, error: data?.error || "查询批次失败" };
} catch (error) {
logCatch(error, "layout_replace_batch_latest", params);
return { success: false, error: "查询批次失败" };
}
},
async markLayoutReplaceBatchItemsAdded(params: {
batchId: string;
items: Array<{ itemId: string; canvasNodeId: string }>;
}): Promise<ApiResponse> {
try {
const response = await fetch(
`/api/canvas/layout-replace/batches/${encodeURIComponent(params.batchId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: params.items }),
}
);
const data = await response.json().catch(() => ({}));
return response.ok && data?.success
? { success: true }
: { success: false, error: data?.error || "更新结果状态失败" };
} catch (error) {
logCatch(error, "layout_replace_batch_mark_added", {
batchId: params.batchId,
});
return { success: false, error: "更新结果状态失败" };
}
},
async retryLayoutReplaceBatchItem(params: {
batchId: string;
itemId: string;
}): Promise<ApiResponse<{ taskId: string }>> {
try {
const response = await fetch(
`/api/canvas/layout-replace/batches/${encodeURIComponent(params.batchId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "retry", itemId: params.itemId }),
}
);
const data = await response.json().catch(() => ({}));
return response.ok && data?.success && data?.taskId
? { success: true, data: { taskId: data.taskId } }
: { success: false, error: data?.error || "重试失败" };
} catch (error) {
logCatch(error, "layout_replace_batch_retry", params);
return { success: false, error: "重试失败" };
}
},
async localEdit(params: {
sourceImageUrl: string;
sourceNodeId?: string;

View File

@ -1,43 +1,105 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { deriveLayoutReplaceBatchAdvance } from "@/lib/canvas/layout-replace-batch-state";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("canvas AI layout replacement entry", () => {
test("keeps the historical AI generation flow wired without routing to PSD", () => {
test("keeps the AI feature wired without routing to PSD", () => {
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
const toolbarConfig = readSource("lib/canvas-toolbar-config.ts");
const capabilityRegistry = readSource("lib/canvas/capabilities/registry.ts");
const route = readSource("app/api/canvas/layout-replace/route.ts");
expect(toolbarConfig).toContain('key: "imageLayoutReplace"');
expect(toolbarConfig).toContain('label: "排版替换"');
expect(toolbarConfig).not.toContain('key: "imagePsdTemplateReplace"');
expect(imageNode).toContain(
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageLayoutReplace"'
);
expect(imageNode).toContain('isSelectedImageActionInline("layoutReplace")');
expect(imageNode).toContain('isSelectedImageActionInMore("layoutReplace")');
expect(imageNode).toContain('handleAction("layout-replace")');
expect(imageNode).toContain("handleOpenLayoutReplace");
expect(imageNode).toContain("handleSubmitLayoutReplace");
expect(imageNode).toContain("imageGenerationService.layoutReplace({");
expect(imageNode).toContain("pollLayoutReplaceTask(taskId)");
expect(imageNode).toContain('editMode: "layout-replace"');
expect(imageNode).toContain("max-h-[calc(100dvh-1rem)]");
expect(imageNode).not.toContain("/psd-template?launch=");
expect(workspace).toContain(
'requestImageNodeAction(selectedImageNodes[0].id, "layout-replace")'
);
expect(capabilityRegistry).toContain('id: "image.layout_replace"');
expect(capabilityRegistry).toContain('clientAction: "layout-replace"');
expect(route).toContain("createCanvasImageTask({");
expect(route).toContain("await enqueueCanvasImageTask(task.id)");
expect(imageNode).toContain("CanvasLayoutReplaceBatchDialog");
expect(imageNode).toContain('handleAction("layout-replace")');
expect(imageNode).not.toContain("handleSubmitLayoutReplace");
expect(imageNode).not.toContain("/psd-template?launch=");
expect(workspace).toContain("selectedImageNodes.length >= 1");
expect(workspace).toContain(".slice(0, 5)");
expect(workspace).toContain('requestImageNodeAction(selectedImageNodes[0].id, "layout-replace"');
});
test("supports five sources, sequential progress, recovery, and canvas writeback", () => {
const dialog = readSource(
"components/canvas/CanvasLayoutReplaceBatchDialog.tsx"
);
const batchRoute = readSource(
"app/api/canvas/layout-replace/batches/route.ts"
);
const batchDetailRoute = readSource(
"app/api/canvas/layout-replace/batches/[batchId]/route.ts"
);
const batchStore = readSource("lib/canvas/layout-replace-batch.ts");
const taskQueue = readSource("lib/canvas/image-task-queue.ts");
const taskExecutor = readSource("lib/canvas/layout-replace-task.ts");
const migration = readSource(
"lib/db/migrations/0098_add_canvas_layout_replace_batches.sql"
);
expect(dialog).toContain("const MAX_BATCH_SOURCES = 5");
expect(dialog).toContain("从画布添加");
expect(dialog).toContain("上传目标模版图");
expect(dialog).toContain("批量处理 {sources.length} 张");
expect(dialog).toContain("添加所选到画布");
expect(dialog).toContain("getLatestLayoutReplaceBatch");
expect(dialog).toContain("markLayoutReplaceBatchItemsAdded");
expect(dialog).toContain("retryLayoutReplaceBatchItem");
expect(dialog).toContain("max-h-[calc(100dvh-1rem)]");
expect(batchRoute).toContain("createCanvasLayoutReplaceBatch({");
expect(batchRoute).toContain("await enqueueCanvasImageTask(created.firstTaskId)");
expect(batchRoute).toContain("ensureCanvasImageTaskActive");
expect(batchDetailRoute).toContain("markCanvasLayoutReplaceBatchItemsAdded");
expect(batchDetailRoute).toContain("retryCanvasLayoutReplaceBatchItem");
expect(batchStore).toContain('status: index === 0 ? "pending" : "waiting"');
expect(batchStore).toContain('status: index === 0 ? "pending" : "queued"');
expect(batchStore).toContain("if (!completedItem) return null");
expect(batchStore).toContain('eq(canvasImageTask.status, "waiting")');
expect(taskQueue).toContain("completeCanvasLayoutReplaceBatchItem");
expect(taskQueue).toContain("ensureCanvasImageTaskActive");
expect(taskExecutor).toContain("saveLayoutReplaceResultToGallery");
expect(taskExecutor).toContain('sourceType: "canvas_layout_replace"');
expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatch"');
expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatchItem"');
});
test("advances one queued item and derives terminal batch states", () => {
const active = deriveLayoutReplaceBatchAdvance([
{ status: "succeeded", taskId: "task-1" },
{ status: "queued", taskId: "task-2" },
{ status: "queued", taskId: "task-3" },
]);
expect(active).toMatchObject({
failedCount: 0,
isComplete: false,
nextTaskId: "task-2",
status: "running",
succeededCount: 1,
});
const partial = deriveLayoutReplaceBatchAdvance([
{ status: "succeeded", taskId: "task-1" },
{ status: "failed", taskId: "task-2" },
]);
expect(partial).toMatchObject({
failedCount: 1,
isComplete: true,
nextTaskId: null,
status: "partial_failed",
succeededCount: 1,
});
});
});