feat(canvas): add durable batch template replacement
This commit is contained in:
parent
71b3ce56cf
commit
3010e51f72
143
app/api/canvas/layout-replace/batches/[batchId]/route.ts
Normal file
143
app/api/canvas/layout-replace/batches/[batchId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
app/api/canvas/layout-replace/batches/route.ts
Normal file
201
app/api/canvas/layout-replace/batches/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
856
components/canvas/CanvasLayoutReplaceBatchDialog.tsx
Normal file
856
components/canvas/CanvasLayoutReplaceBatchDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
32
lib/canvas/layout-replace-batch-state.ts
Normal file
32
lib/canvas/layout-replace-batch-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
534
lib/canvas/layout-replace-batch.ts
Normal file
534
lib/canvas/layout-replace-batch.ts
Normal 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);
|
||||
}
|
||||
@ -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(
|
||||
``,
|
||||
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;
|
||||
}
|
||||
|
||||
48
lib/db/migrations/0098_add_canvas_layout_replace_batches.sql
Normal file
48
lib/db/migrations/0098_add_canvas_layout_replace_batches.sql
Normal 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");
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user