feat(agent): add AI template reuse to main results

This commit is contained in:
134 2026-08-13 18:18:53 +08:00
parent 9ff3dc68ce
commit 675966a783
10 changed files with 1836 additions and 38 deletions

View File

@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
} from "@/lib/canvas/image-task-queue";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import type { CanvasLayoutReplaceTaskRequest } from "@/lib/canvas/layout-replace-task";
export const maxDuration = 1200;
function normalizeHttpUrl(value: unknown) {
const url = String(value || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function normalizeUuid(value: unknown) {
const uuid = 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(uuid)
? uuid
: undefined;
}
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 sourceImageUrl = normalizeHttpUrl(body?.sourceImageUrl);
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
if (!sourceImageUrl) {
return NextResponse.json(
{ success: false, error: "缺少当前产品图片" },
{ status: 400 },
);
}
if (!referenceImageUrl) {
return NextResponse.json(
{ success: false, error: "缺少排版参考图" },
{ status: 400 },
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
reason: "image_repaint",
modelId: "canvas-layout-replace",
note: "排版替换消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, {
status: pointsCharge.status,
});
}
const taskRequest: CanvasLayoutReplaceTaskRequest = {
taskKind: "canvas-layout-replace",
sourceImageUrl,
referenceImageUrl,
supplementalPrompt:
typeof body?.supplementalPrompt === "string"
? body.supplementalPrompt
: undefined,
model: typeof body?.model === "string" ? body.model : undefined,
imageSize:
typeof body?.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K",
sourceNodeId:
typeof body?.sourceNodeId === "string" ? body.sourceNodeId : undefined,
agentProjectId: normalizeUuid(body?.agentProjectId),
referenceLabel:
typeof body?.referenceLabel === "string" ? body.referenceLabel : undefined,
debugMeta:
body?.debugMeta &&
typeof body.debugMeta === "object" &&
!Array.isArray(body.debugMeta)
? body.debugMeta
: undefined,
};
const task = await createCanvasImageTask({
userId: session.user.id,
request: taskRequest as unknown as Record<string, unknown>,
});
await enqueueCanvasImageTask(task.id);
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `排版替换消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume canvas layout replace points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
status: task.status,
});
} catch (error) {
console.error("[Canvas Layout Replace] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "排版替换提交失败",
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,315 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { LayoutTemplate, Loader2, Upload } from "lucide-react";
import { toast } from "sonner";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { imageGenerationService } from "@/services/canvas";
const POLL_INTERVAL_MS = 2500;
const POLL_LIMIT = 240;
export type AgentAiTemplateResult = {
imageUrl: string;
sourceImageUrl: string;
referenceImageUrl: string;
};
type AgentAiTemplateDialogProps = {
open: boolean;
sourceImageUrl?: string | null;
model?: string | null;
imageSize?: string | null;
projectId: string;
onOpenChange: (open: boolean) => void;
onProcessingChange?: (processing: boolean, sourceImageUrl: string) => void;
onResultCreated: (result: AgentAiTemplateResult) => void | Promise<void>;
};
function isHttpImageUrl(value?: string | null) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function pollAiTemplateTask(taskId: string) {
for (let attempt = 0; attempt < POLL_LIMIT; attempt += 1) {
await sleep(POLL_INTERVAL_MS);
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
if (!taskResult.success || !task) {
continue;
}
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
if (!isHttpImageUrl(imageUrl)) {
throw new Error("AI套模版已完成但未返回结果图片");
}
return imageUrl;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "AI套模版生成失败");
}
}
throw new Error("AI套模版任务等待超时,请稍后在结果区查看");
}
export function AgentAiTemplateDialog({
open,
sourceImageUrl,
model,
imageSize,
projectId,
onOpenChange,
onProcessingChange,
onResultCreated,
}: AgentAiTemplateDialogProps) {
const referenceUploadId = useId();
const [referenceImageUrl, setReferenceImageUrl] = useState("");
const [supplementalPrompt, setSupplementalPrompt] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const normalizedSourceUrl = String(sourceImageUrl || "").trim();
const onProcessingChangeRef = useRef(onProcessingChange);
const onResultCreatedRef = useRef(onResultCreated);
useEffect(() => {
onProcessingChangeRef.current = onProcessingChange;
onResultCreatedRef.current = onResultCreated;
}, [onProcessingChange, onResultCreated]);
useEffect(() => {
if (!open || isSubmitting) return;
setReferenceImageUrl("");
setSupplementalPrompt("");
setIsUploading(false);
}, [isSubmitting, open, normalizedSourceUrl]);
const handleReferenceUpload = async (file?: File) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const payload = await response.json().catch(() => null);
const uploadedUrl = String(payload?.url || "").trim();
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
throw new Error(payload?.error || "参考图上传失败");
}
setReferenceImageUrl(uploadedUrl);
toast.success("版式参考图已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setIsUploading(false);
}
};
const handleSubmit = async () => {
const normalizedReferenceUrl = referenceImageUrl.trim();
if (!isHttpImageUrl(normalizedSourceUrl)) {
toast.error("当前主图结果地址不可用");
return;
}
if (!isHttpImageUrl(normalizedReferenceUrl)) {
toast.error("请先上传或填写版式参考图");
return;
}
if (isSubmitting) return;
setIsSubmitting(true);
onProcessingChangeRef.current?.(true, normalizedSourceUrl);
try {
const submitResult = await imageGenerationService.layoutReplace({
sourceImageUrl: normalizedSourceUrl,
referenceImageUrl: normalizedReferenceUrl,
supplementalPrompt: supplementalPrompt.trim() || undefined,
model: model && model !== "auto" ? model : undefined,
imageSize: imageSize || "2K",
agentProjectId: projectId,
referenceLabel: "风格化主图 AI 套模版参考图",
debugMeta: {
operation: "agent-main-ai-template",
source: "agent-main-result-card",
},
});
const taskId = String(submitResult.data?.taskId || "").trim();
if (!submitResult.success || !taskId) {
throw new Error(submitResult.error || "AI套模版提交失败");
}
onOpenChange(false);
toast.success("AI套模版已进入生图队列");
const resultImageUrl = await pollAiTemplateTask(taskId);
await onResultCreatedRef.current({
imageUrl: resultImageUrl,
sourceImageUrl: normalizedSourceUrl,
referenceImageUrl: normalizedReferenceUrl,
});
toast.success("AI套模版结果已加入主图结果区");
} catch (error) {
toast.error(error instanceof Error ? error.message : "AI套模版生成失败");
} finally {
setIsSubmitting(false);
onProcessingChangeRef.current?.(false, normalizedSourceUrl);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isUploading) {
onOpenChange(nextOpen);
}
}}
>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[780px] flex-col gap-0 overflow-hidden border-white/10 bg-[#09090b] p-0 text-white sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
<DialogHeader className="shrink-0 border-b border-white/10 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
<DialogTitle className="flex items-center gap-2 text-base">
<LayoutTemplate className="h-4 w-4" />
AI套模版
</DialogTitle>
<DialogDescription className="text-white/55">
保留参考图的版式、文字和图标,仅将产品主体替换为当前主图。
</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(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-zinc-900">
{isHttpImageUrl(normalizedSourceUrl) ? (
<AgentPreviewImage
alt="当前主图结果"
className="max-h-full max-w-full object-contain"
eager
previewQuality={86}
previewWidth={720}
src={normalizedSourceUrl}
/>
) : null}
</div>
</div>
<div className="min-w-0 space-y-2">
<Label htmlFor={referenceUploadId}>版式参考图</Label>
<div className="flex gap-2">
<Input
className="min-w-0 border-white/10 bg-white/[0.04]"
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
placeholder="填写图片链接或上传"
value={referenceImageUrl}
/>
<Button
asChild
className="shrink-0 border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
variant="outline"
>
<label htmlFor={referenceUploadId}>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传
</label>
</Button>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="sr-only"
disabled={isUploading || isSubmitting}
id={referenceUploadId}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleReferenceUpload(file);
}}
type="file"
/>
</div>
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/10 bg-zinc-900">
{isUploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white/45" />
) : isHttpImageUrl(referenceImageUrl) ? (
<AgentPreviewImage
alt="AI套模版参考图"
className="max-h-full max-w-full object-contain"
eager
previewQuality={86}
previewWidth={720}
src={referenceImageUrl}
/>
) : (
<LayoutTemplate className="h-7 w-7 text-white/20" />
)}
</div>
</div>
</div>
<div className="mt-4 space-y-2">
<Label htmlFor={`${referenceUploadId}-prompt`}>补充要求</Label>
<Textarea
className="min-h-[84px] max-h-[180px] border-white/10 bg-white/[0.04]"
id={`${referenceUploadId}-prompt`}
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
placeholder="可选:补充产品材质、颜色或替换细节"
value={supplementalPrompt}
/>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-white/10 bg-black/25 px-4 py-3 sm:px-6">
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.10] hover:text-white"
disabled={isUploading}
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
取消
</Button>
<Button
disabled={isSubmitting || isUploading || !isHttpImageUrl(referenceImageUrl)}
onClick={() => void handleSubmit()}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-2 h-4 w-4" />
)}
{isSubmitting ? "提交中" : "开始AI套模版"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -26,6 +26,7 @@ import {
Download,
GitCompareArrows,
ImagePlus,
LayoutTemplate,
Loader2,
Maximize2,
Sparkles,
@ -64,6 +65,10 @@ import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-p
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
import {
AgentAiTemplateDialog,
type AgentAiTemplateResult,
} from "@/components/agents/AgentAiTemplateDialog";
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import {
@ -3236,6 +3241,10 @@ export function AgentGenerationWorkspaceDarkV2({
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
useState<AgentGenerationResultModeType>("scene_generation");
const [aiTemplateDialogSourceUrl, setAiTemplateDialogSourceUrl] =
useState<string | null>(null);
const [aiTemplateProcessingSourceUrl, setAiTemplateProcessingSourceUrl] =
useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] =
useState<null | BatchDownloadScope>(null);
const [batchDownloadDialog, setBatchDownloadDialog] =
@ -6818,6 +6827,23 @@ export function AgentGenerationWorkspaceDarkV2({
}, 2800);
}, []);
const handleAiTemplateResultCreated = useCallback(
(result: AgentAiTemplateResult) => {
const appended = appendResultUrlToCollection("scene_generation", result.imageUrl, {
compareSourceUrl: result.sourceImageUrl,
});
if (!appended) {
toast.error("当前项目未接入结果保存能力");
return;
}
setActiveResultModeType("scene_generation");
setSelectedResultUrl(result.imageUrl);
showProcessedResultHint(result.imageUrl, "AI套模版完成");
},
[appendResultUrlToCollection, showProcessedResultHint]
);
const updateDetailGenerationProcessedResult = useCallback(
(params: {
cardId?: string | null;
@ -10885,12 +10911,21 @@ export function AgentGenerationWorkspaceDarkV2({
className="flex flex-1 flex-col px-6 pb-6 pt-5 md:px-8"
>
<div className="mx-auto w-full max-w-[1200px]">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div
className={cn(
"grid gap-4",
activeResultModeType === "scene_generation"
? "grid-cols-1 md:grid-cols-2"
: "grid-cols-2 md:grid-cols-4"
)}
>
{gridResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation";
const isDetailGenerationCard =
activeResultModeType === "detail_generation";
const isAiTemplateProcessing =
aiTemplateProcessingSourceUrl === card.url;
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const isDetailLocalEditBusy =
detailLocalEditSubmittingId === card.id;
@ -10953,36 +10988,70 @@ export function AgentGenerationWorkspaceDarkV2({
/>
{isSceneResultCard ? (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setSceneResizeEditorTargetUrl(card.url!);
}}
className={cn("absolute left-3 top-3 flex h-9 w-9 items-center justify-center", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS, "px-0 py-0")}
aria-label="调整尺寸"
>
<Maximize2 className="h-4 w-4" />
</button>
{isPsdTemplateEnabled ? (
<div className="absolute left-3 right-14 top-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
setSceneResizeEditorTargetUrl(card.url!);
}}
className={cn("absolute left-14 top-3 flex items-center gap-1", IMAGE_OVERLAY_ACTION_CLASS)}
aria-label="套用 PSD 模板"
className={cn(
"flex h-9 w-9 items-center justify-center px-0 py-0",
IMAGE_OVERLAY_ACTIVE_BADGE_CLASS
)}
aria-label="调整尺寸"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
<Maximize2 className="h-4 w-4" />
</button>
) : null}
{isPsdTemplateEnabled ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(
activeResultModeType
),
});
}}
className={cn(
"flex items-center gap-1",
IMAGE_OVERLAY_ACTION_CLASS
)}
aria-label="套用 PSD 模板"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
) : null}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setAiTemplateDialogSourceUrl(card.url!);
}}
disabled={Boolean(aiTemplateProcessingSourceUrl)}
className={cn(
"flex items-center gap-1 disabled:cursor-not-allowed disabled:opacity-55",
IMAGE_OVERLAY_ACTION_CLASS
)}
aria-label="AI套模版"
aria-busy={isAiTemplateProcessing}
>
{isAiTemplateProcessing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LayoutTemplate className="h-3.5 w-3.5" />
)}
<span>
{isAiTemplateProcessing ? "AI套版中" : "AI套模版"}
</span>
</button>
</div>
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/25 bg-zinc-950/82 px-3 py-2 font-semibold text-[11px] text-white shadow-[0_12px_34px_rgba(0,0,0,0.48)] backdrop-blur-md">
双击查看大图
</div>
@ -11230,6 +11299,29 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
</main>
<AgentAiTemplateDialog
imageSize={imageParams.imageSize}
model={selectedModelId !== "auto" ? selectedModelId : effectiveDefaultModelId}
projectId={projectId}
onOpenChange={(open) => {
if (!open) {
setAiTemplateDialogSourceUrl(null);
}
}}
onProcessingChange={(processing, sourceImageUrl) => {
setAiTemplateProcessingSourceUrl((current) =>
processing
? sourceImageUrl
: current === sourceImageUrl
? null
: current
);
}}
onResultCreated={handleAiTemplateResultCreated}
open={Boolean(aiTemplateDialogSourceUrl)}
sourceImageUrl={aiTemplateDialogSourceUrl}
/>
{sceneResizeEditorTargetUrl ? (
<ImageAnnotator
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}

View File

@ -0,0 +1,67 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { appendAgentProjectResultForSave } from "@/lib/agents/agent-project-save-merge";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import type { AgentGenerationResultModeType } from "@/types/agent";
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function getCanvasDataRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export async function persistAgentProjectGeneratedResult(params: {
projectId: string;
userId: string;
imageUrl: string;
modeType: AgentGenerationResultModeType;
}) {
const projectId = String(params.projectId || "").trim();
const userId = String(params.userId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
if (!UUID_PATTERN.test(projectId) || !UUID_PATTERN.test(userId) || !/^https?:\/\//i.test(imageUrl)) {
return false;
}
return db.transaction(async (tx) => {
const [project] = await tx
.select()
.from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.limit(1)
.for("update");
if (!project || !isAgentProjectRow(project)) {
return false;
}
const nextProjectState = appendAgentProjectResultForSave({
existingState: extractAgentProjectStateFromCanvasData(project.canvasData),
modeType: params.modeType,
imageUrl,
});
const canvasData = getCanvasDataRecord(project.canvasData);
const [updatedProject] = await tx
.update(canvasProject)
.set({
canvasData: {
...canvasData,
nodes: Array.isArray(canvasData.nodes) ? canvasData.nodes : [],
agentProjectState: nextProjectState,
} as unknown as typeof canvasProject.$inferInsert.canvasData,
thumbnail: project.thumbnail || imageUrl,
workspaceType: "agent",
updatedAt: new Date(),
})
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.returning({ id: canvasProject.id });
return Boolean(updatedProject);
});
}

View File

@ -0,0 +1,425 @@
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type {
AgentDetailReplacementReferenceItem,
AgentGenerationProjectPersistedState,
AgentGenerationReplacementResultRecord,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
AgentWorkflowBusinessModeKey,
} from "@/types/agent";
const RESULT_COLLECTION_KEYS = [
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as const;
const REPLACEMENT_RESULT_KEYS = [
"detail_replacement",
"sku_replacement",
] as const;
function createFallbackWorkspaceState() {
return {
input: "",
attachments: [],
selectedModelId: "auto",
activeBusinessModeKey: "main_sku" as const,
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
},
};
}
function normalizeProjectResultUrlList(urls: string[] | null | undefined) {
return Array.from(
new Set(
(urls || [])
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
}
function mergeResultCollections(
existing: AgentGenerationProjectResultCollections | null | undefined,
incoming: AgentGenerationProjectResultCollections | null | undefined
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(existing?.[key]),
...normalizeProjectResultUrlList(incoming?.[key]),
]);
return next;
},
{}
);
}
function getResultCollectionUrlSet(
collections: AgentGenerationProjectResultCollections | null | undefined
) {
return new Set(
RESULT_COLLECTION_KEYS.flatMap((key) => normalizeProjectResultUrlList(collections?.[key]))
);
}
function removeDeletedUrlsFromCollections(
collections: AgentGenerationProjectResultCollections,
deletedUrls: Set<string>
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList(collections[key]).filter(
(url) => !deletedUrls.has(url)
);
return next;
},
{}
);
}
function mergeReplacementResultState(
existing: AgentGenerationProjectReplacementResultState | null | undefined,
incoming: AgentGenerationProjectReplacementResultState | null | undefined,
deletedUrls: Set<string>
): AgentGenerationProjectReplacementResultState {
return REPLACEMENT_RESULT_KEYS.reduce<AgentGenerationProjectReplacementResultState>(
(next, key) => {
const existingModeState = existing?.[key] || {};
const incomingModeState = incoming?.[key] || {};
const mergedModeState = Array.from(
new Set([...Object.keys(existingModeState), ...Object.keys(incomingModeState)])
).reduce<Record<string, AgentGenerationReplacementResultRecord>>(
(records, cardId) => {
const existingRecord = existingModeState[cardId];
const incomingRecord = incomingModeState[cardId];
if (!existingRecord) {
if (incomingRecord) {
records[cardId] = incomingRecord;
}
return records;
}
if (!incomingRecord) {
records[cardId] = existingRecord;
return records;
}
const existingUpdatedAt = Number(existingRecord.updatedAt) || 0;
const incomingUpdatedAt = Number(incomingRecord.updatedAt) || 0;
records[cardId] =
incomingUpdatedAt >= existingUpdatedAt ? incomingRecord : existingRecord;
return records;
},
{}
);
next[key] = Object.fromEntries(
Object.entries(mergedModeState).filter(([, record]) => {
const url = String(record?.url || "").trim();
return !url || !deletedUrls.has(url);
})
);
return next;
},
{}
);
}
function getReplacementResultUrls(
state: AgentGenerationProjectReplacementResultState | null | undefined
) {
return REPLACEMENT_RESULT_KEYS.flatMap((key) =>
Object.values(state?.[key] || {})
.map((record) => String(record?.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
);
}
export function getReplacementResultUrlsFromAgentProjectState(
state: AgentGenerationProjectPersistedState | null | undefined
) {
const normalized = normalizeAgentGenerationProjectPersistedState(state);
return normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(
normalized.resultCollections?.detail_replacement
),
...normalizeProjectResultUrlList(
normalized.resultCollections?.sku_replacement
),
...getReplacementResultUrls(normalized.replacementResultState),
]);
}
export function doesAgentProjectStateCoverReplacementResults(params: {
candidateState: AgentGenerationProjectPersistedState | null | undefined;
authoritativeState: AgentGenerationProjectPersistedState | null | undefined;
}) {
const candidateUrls = new Set(
getReplacementResultUrlsFromAgentProjectState(params.candidateState)
);
return getReplacementResultUrlsFromAgentProjectState(
params.authoritativeState
).every((url) => candidateUrls.has(url));
}
export function getAllResultUrlsFromAgentProjectState(
state: AgentGenerationProjectPersistedState
) {
return normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(state.resultImageUrls),
...RESULT_COLLECTION_KEYS.flatMap((key) =>
normalizeProjectResultUrlList(state.resultCollections?.[key])
),
...getReplacementResultUrls(state.replacementResultState),
]);
}
function hasDetailModeSignals(state: AgentGenerationProjectPersistedState) {
const detailReferenceCount =
state.workflowState.agentModeState?.detailReplacement?.references?.length || 0;
const skuReferenceCount =
state.workflowState.agentModeState?.skuReplacement?.references?.length || 0;
return (
detailReferenceCount > 0 ||
skuReferenceCount > 0 ||
normalizeProjectResultUrlList(state.resultCollections?.detail_generation).length > 0 ||
normalizeProjectResultUrlList(state.resultCollections?.detail_replacement).length > 0 ||
normalizeProjectResultUrlList(state.resultCollections?.sku_replacement).length > 0 ||
getReplacementResultUrls(state.replacementResultState).length > 0
);
}
function hasDetailReplacementResultSignals(params: {
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
return (
normalizeProjectResultUrlList(params.resultCollections.detail_replacement).length > 0 ||
getReplacementResultUrls({
detail_replacement: params.replacementResultState.detail_replacement,
}).length > 0
);
}
function hasSkuReplacementResultSignals(params: {
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
return (
normalizeProjectResultUrlList(params.resultCollections.sku_replacement).length > 0 ||
getReplacementResultUrls({
sku_replacement: params.replacementResultState.sku_replacement,
}).length > 0
);
}
function mergeWorkflowStateForSave(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: AgentGenerationProjectPersistedState;
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
const incomingAgentModeState = params.incomingState.workflowState.agentModeState;
const existingAgentModeState = params.existingState.workflowState.agentModeState;
const mergeProtectedReferences = (
existingReferences: AgentDetailReplacementReferenceItem[],
incomingReferences: AgentDetailReplacementReferenceItem[],
resultRecords: Record<string, AgentGenerationReplacementResultRecord>,
hasResultSignals: boolean
) => {
if (!hasResultSignals || existingReferences.length === 0) {
return incomingReferences;
}
const protectedCardIds = new Set(
Object.entries(resultRecords)
.filter(([, record]) => Boolean(record?.url))
.map(([cardId]) => cardId)
);
const existingReferenceIds = new Set(
existingReferences.map((reference) => reference.id)
);
const incomingIsExistingSubset =
incomingReferences.length < existingReferences.length &&
incomingReferences.every((reference) =>
existingReferenceIds.has(reference.id)
);
const protectedExistingReferences =
incomingReferences.length === 0 || incomingIsExistingSubset
? existingReferences
: protectedCardIds.size > 0
? existingReferences.filter((reference) =>
protectedCardIds.has(reference.id)
)
: [];
if (protectedExistingReferences.length === 0) {
return incomingReferences;
}
const protectedById = new Map(
protectedExistingReferences.map((reference) => [reference.id, reference])
);
const mergedReferences = incomingReferences.map(
(reference) => protectedById.get(reference.id) || reference
);
const mergedIds = new Set(mergedReferences.map((reference) => reference.id));
protectedExistingReferences.forEach((reference) => {
if (!mergedIds.has(reference.id)) {
mergedReferences.push(reference);
}
});
return mergedReferences;
};
const detailReferences = mergeProtectedReferences(
existingAgentModeState?.detailReplacement?.references || [],
incomingAgentModeState?.detailReplacement?.references || [],
params.replacementResultState.detail_replacement || {},
hasDetailReplacementResultSignals(params)
);
const skuReferences = mergeProtectedReferences(
existingAgentModeState?.skuReplacement?.references || [],
incomingAgentModeState?.skuReplacement?.references || [],
params.replacementResultState.sku_replacement || {},
hasSkuReplacementResultSignals(params)
);
return {
...params.incomingState.workflowState,
agentModeState: {
...incomingAgentModeState,
detailReplacement: {
...incomingAgentModeState?.detailReplacement,
references: detailReferences,
},
skuReplacement: {
...incomingAgentModeState?.skuReplacement,
references: skuReferences,
},
},
};
}
function hasExplicitIncomingBusinessMode(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const raw = value as Record<string, unknown>;
const workspaceState =
raw.workspaceState && typeof raw.workspaceState === "object" && !Array.isArray(raw.workspaceState)
? (raw.workspaceState as Record<string, unknown>)
: {};
return Boolean(String(workspaceState.activeBusinessModeKey || "").trim());
}
function resolveMergedBusinessModeKey(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: AgentGenerationProjectPersistedState;
rawIncomingState: unknown;
mergedState: AgentGenerationProjectPersistedState;
}): AgentWorkflowBusinessModeKey {
if (hasDetailModeSignals(params.mergedState)) {
const incomingKey = params.incomingState.workspaceState?.activeBusinessModeKey;
const existingKey = params.existingState.workspaceState?.activeBusinessModeKey;
const incomingHasExplicitKey = hasExplicitIncomingBusinessMode(params.rawIncomingState);
if (!incomingHasExplicitKey || existingKey === "detail" || incomingKey === "main_sku") {
return "detail";
}
}
return params.incomingState.workspaceState?.activeBusinessModeKey ||
params.existingState.workspaceState?.activeBusinessModeKey ||
"main_sku";
}
export function mergeAgentProjectStateForSave(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: unknown;
}): AgentGenerationProjectPersistedState {
const existingState = normalizeAgentGenerationProjectPersistedState(params.existingState);
const incomingState = normalizeAgentGenerationProjectPersistedState(params.incomingState);
const deletedResultCollections = mergeResultCollections(
existingState.deletedResultCollections,
incomingState.deletedResultCollections
);
const deletedUrls = getResultCollectionUrlSet(deletedResultCollections);
const replacementResultState = mergeReplacementResultState(
existingState.replacementResultState,
incomingState.replacementResultState,
deletedUrls
);
const resultCollections = removeDeletedUrlsFromCollections(
mergeResultCollections(existingState.resultCollections, incomingState.resultCollections),
deletedUrls
);
const workflowState = mergeWorkflowStateForSave({
existingState,
incomingState,
resultCollections,
replacementResultState,
});
const mergedState = normalizeAgentGenerationProjectPersistedState({
...incomingState,
workflowState,
resultCollections,
deletedResultCollections,
replacementResultState,
resultImageUrls: normalizeProjectResultUrlList([
...getAllResultUrlsFromAgentProjectState(existingState),
...getAllResultUrlsFromAgentProjectState(incomingState),
...RESULT_COLLECTION_KEYS.flatMap((key) => resultCollections[key] || []),
...getReplacementResultUrls(replacementResultState),
]).filter((url) => !deletedUrls.has(url)),
});
const workspaceState = mergedState.workspaceState || createFallbackWorkspaceState();
return {
...mergedState,
workspaceState: {
...workspaceState,
activeBusinessModeKey: resolveMergedBusinessModeKey({
existingState,
incomingState,
rawIncomingState: params.incomingState,
mergedState,
}),
},
};
}
export function appendAgentProjectResultForSave(params: {
existingState: AgentGenerationProjectPersistedState;
modeType: AgentGenerationResultModeType;
imageUrl: string;
}): AgentGenerationProjectPersistedState {
const existingState = normalizeAgentGenerationProjectPersistedState(
params.existingState
);
const imageUrl = String(params.imageUrl || "").trim();
if (!/^https?:\/\//i.test(imageUrl)) {
return existingState;
}
const resultCollections: AgentGenerationProjectResultCollections = {
...existingState.resultCollections,
[params.modeType]: normalizeProjectResultUrlList([
...(existingState.resultCollections?.[params.modeType] || []),
imageUrl,
]),
};
return mergeAgentProjectStateForSave({
existingState,
incomingState: {
...existingState,
resultCollections,
},
});
}

View File

@ -21,9 +21,18 @@ import {
type StyleProTaskRequest,
type StyleProTaskResult,
} from "@/lib/style-pro/task";
import {
executeCanvasLayoutReplaceTask,
isCanvasLayoutReplaceTaskRequest,
type CanvasLayoutReplaceTaskRequest,
} from "@/lib/canvas/layout-replace-task";
import { persistAgentProjectGeneratedResult } from "@/lib/agents/agent-project-result-persistence";
type CanvasImageTaskStatus = "pending" | "running" | "succeeded" | "failed";
type CanvasImageTaskRequest = CanvasGenerateImageRequest | StyleProTaskRequest;
type CanvasImageTaskRequest =
| CanvasGenerateImageRequest
| StyleProTaskRequest
| CanvasLayoutReplaceTaskRequest;
type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult;
export type CanvasImageTaskQueueStatus = {
@ -469,25 +478,52 @@ async function runSingleTask(entry: PendingTaskEntry) {
request,
userId: String(task.userId || entry.userId),
})
: (async () => {
const imageRequest = request as CanvasGenerateImageRequest;
const ownerKey = String(task.userId || entry.userId);
const initialResult = await generateCanvasImage({
...imageRequest,
ownerKey,
});
return await applyScaleAdjustModelRelight({
generateImage: generateCanvasImage,
initialResult,
ownerKey,
request: imageRequest,
});
})();
: isCanvasLayoutReplaceTaskRequest(request)
? executeCanvasLayoutReplaceTask({
request,
userId: String(task.userId || entry.userId),
})
: (async () => {
const imageRequest = request as CanvasGenerateImageRequest;
const ownerKey = String(task.userId || entry.userId);
const initialResult = await generateCanvasImage({
...imageRequest,
ownerKey,
});
return await applyScaleAdjustModelRelight({
generateImage: generateCanvasImage,
initialResult,
ownerKey,
request: imageRequest,
});
})();
const result = await withCanvasImageTaskExecutionTimeout(
execution,
runningTimeoutMs,
entry.taskId
);
if (isCanvasLayoutReplaceTaskRequest(request) && request.agentProjectId) {
const layoutReplaceResult = result as CanvasGenerateImageResult;
const generatedImageUrl = String(
layoutReplaceResult.images?.[0]?.originalUrl ||
layoutReplaceResult.images?.[0]?.thumbnailUrl ||
""
).trim();
try {
await persistAgentProjectGeneratedResult({
projectId: request.agentProjectId,
userId: String(task.userId || entry.userId),
imageUrl: generatedImageUrl,
modeType: "scene_generation",
});
} catch (error) {
console.error("[Canvas Image Task Queue] Failed to persist agent AI template result:", {
error: error instanceof Error ? error.message : String(error),
projectId: request.agentProjectId,
taskId: entry.taskId,
});
}
}
await db
.update(canvasImageTask)
.set({

View File

@ -0,0 +1,636 @@
import "server-only";
import {
analyzeReplacementExecutionPlan,
buildAgentReplacementPrompt,
createAgentImageThumbnailBuffer,
normalizeReplacementResultImage,
resolveDirectReplacementModelRef,
resolveReplacementReferenceGeometry,
resolveReplacementScenePlanningRuntime,
type AgentDetailReplacementStrategy,
} from "@/app/(chat)/api/chat/_lib/detail-replacement";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import {
generateCanvasImage,
type CanvasGenerateImageResult,
} from "@/lib/canvas/generate-image";
import { uploadFile } from "@/lib/storage/service";
export interface CanvasLayoutReplaceTaskRequest {
taskKind: "canvas-layout-replace";
sourceImageUrl: string;
referenceImageUrl: string;
supplementalPrompt?: string;
model?: string;
imageSize?: string;
sourceNodeId?: string;
agentProjectId?: string;
referenceLabel?: string;
debugMeta?: Record<string, unknown>;
}
function normalizeHttpUrl(value: unknown) {
const url = String(value || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
export function isCanvasLayoutReplaceTaskRequest(
request: unknown,
): request is CanvasLayoutReplaceTaskRequest {
return Boolean(
request &&
typeof request === "object" &&
!Array.isArray(request) &&
(request as { taskKind?: unknown }).taskKind === "canvas-layout-replace",
);
}
function normalizeLayoutReplacePromptSupplement(value: unknown) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 600);
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function selectLayoutPatchMask(params: {
generated: Buffer;
height: number;
reference: Buffer;
width: number;
}) {
const { generated, height, reference, width } = params;
const pixelCount = width * height;
const rawMask = new Uint8Array(pixelCount);
const diffThreshold = 34;
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const offset = pixelIndex * 3;
const diff = Math.max(
Math.abs((generated[offset] || 0) - (reference[offset] || 0)),
Math.abs((generated[offset + 1] || 0) - (reference[offset + 1] || 0)),
Math.abs((generated[offset + 2] || 0) - (reference[offset + 2] || 0)),
);
if (diff >= diffThreshold) {
rawMask[pixelIndex] = 1;
}
}
const visited = new Uint8Array(pixelCount);
const queue = new Int32Array(pixelCount);
const components: Array<{
area: number;
maxX: number;
maxY: number;
minX: number;
minY: number;
}> = [];
for (let start = 0; start < pixelCount; start += 1) {
if (!rawMask[start] || visited[start]) continue;
let head = 0;
let tail = 0;
let area = 0;
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
queue[tail] = start;
tail += 1;
visited[start] = 1;
while (head < tail) {
const current = queue[head];
head += 1;
area += 1;
const x = current % width;
const y = Math.floor(current / width);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
const left = current - 1;
const right = current + 1;
const up = current - width;
const down = current + width;
if (x > 0 && rawMask[left] && !visited[left]) {
visited[left] = 1;
queue[tail] = left;
tail += 1;
}
if (x < width - 1 && rawMask[right] && !visited[right]) {
visited[right] = 1;
queue[tail] = right;
tail += 1;
}
if (y > 0 && rawMask[up] && !visited[up]) {
visited[up] = 1;
queue[tail] = up;
tail += 1;
}
if (y < height - 1 && rawMask[down] && !visited[down]) {
visited[down] = 1;
queue[tail] = down;
tail += 1;
}
}
components.push({ area, maxX, maxY, minX, minY });
}
const minArea = Math.max(900, Math.round(pixelCount * 0.0015));
const minBoxArea = Math.max(3200, Math.round(pixelCount * 0.008));
const candidates = components
.filter((component) => {
const boxArea =
(component.maxX - component.minX + 1) *
(component.maxY - component.minY + 1);
return component.area >= minArea && boxArea >= minBoxArea;
})
.sort((left, right) => right.area - left.area);
const primary = candidates[0];
if (!primary) {
return null;
}
const selected = candidates.filter((component, index) => {
if (index === 0) return true;
if (component.area >= primary.area * 0.18) return true;
const expandedPrimary = {
maxX: Math.min(width - 1, primary.maxX + Math.round(width * 0.05)),
maxY: Math.min(height - 1, primary.maxY + Math.round(height * 0.05)),
minX: Math.max(0, primary.minX - Math.round(width * 0.05)),
minY: Math.max(0, primary.minY - Math.round(height * 0.05)),
};
return !(
component.maxX < expandedPrimary.minX ||
component.minX > expandedPrimary.maxX ||
component.maxY < expandedPrimary.minY ||
component.minY > expandedPrimary.maxY
);
});
const selectedMask = new Uint8Array(pixelCount);
for (const component of selected) {
for (let y = component.minY; y <= component.maxY; y += 1) {
const rowOffset = y * width;
for (let x = component.minX; x <= component.maxX; x += 1) {
const index = rowOffset + x;
if (rawMask[index]) selectedMask[index] = 255;
}
}
}
return {
mask: selectedMask,
maskSource: "generated-diff",
selectedComponentCount: selected.length,
selectedPixelCount: selected.reduce((sum, component) => sum + component.area, 0),
};
}
function selectReferenceProductMask(params: {
height: number;
reference: Buffer;
width: number;
}) {
const { height, reference, width } = params;
const pixelCount = width * height;
const rawMask = new Uint8Array(pixelCount);
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const offset = pixelIndex * 3;
const red = reference[offset] || 0;
const green = reference[offset + 1] || 0;
const blue = reference[offset + 2] || 0;
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
const channelSpan = Math.max(red, green, blue) - Math.min(red, green, blue);
if (luminance <= 222 || channelSpan >= 38) {
rawMask[pixelIndex] = 1;
}
}
const visited = new Uint8Array(pixelCount);
const queue = new Int32Array(pixelCount);
const components: Array<{
area: number;
boxArea: number;
maxX: number;
maxY: number;
minX: number;
minY: number;
}> = [];
for (let start = 0; start < pixelCount; start += 1) {
if (!rawMask[start] || visited[start]) continue;
let head = 0;
let tail = 0;
let area = 0;
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
queue[tail] = start;
tail += 1;
visited[start] = 1;
while (head < tail) {
const current = queue[head];
head += 1;
area += 1;
const x = current % width;
const y = Math.floor(current / width);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
const left = current - 1;
const right = current + 1;
const up = current - width;
const down = current + width;
if (x > 0 && rawMask[left] && !visited[left]) {
visited[left] = 1;
queue[tail] = left;
tail += 1;
}
if (x < width - 1 && rawMask[right] && !visited[right]) {
visited[right] = 1;
queue[tail] = right;
tail += 1;
}
if (y > 0 && rawMask[up] && !visited[up]) {
visited[up] = 1;
queue[tail] = up;
tail += 1;
}
if (y < height - 1 && rawMask[down] && !visited[down]) {
visited[down] = 1;
queue[tail] = down;
tail += 1;
}
}
const boxArea = (maxX - minX + 1) * (maxY - minY + 1);
components.push({ area, boxArea, maxX, maxY, minX, minY });
}
const minArea = Math.max(1400, Math.round(pixelCount * 0.002));
const minBoxArea = Math.max(6000, Math.round(pixelCount * 0.012));
const candidates = components
.filter((component) => {
const boxWidth = component.maxX - component.minX + 1;
const boxHeight = component.maxY - component.minY + 1;
const aspect = boxWidth / Math.max(1, boxHeight);
const centerY = (component.minY + component.maxY) / 2;
const looksLikeCtaButton = aspect >= 6 && boxHeight <= height * 0.08;
const looksLikeBottomUtility = centerY >= height * 0.78 && aspect >= 3.5;
return (
component.area >= minArea &&
component.boxArea >= minBoxArea &&
!looksLikeCtaButton &&
!looksLikeBottomUtility
);
})
.sort((left, right) => right.area - left.area);
const primary = candidates[0];
if (!primary) {
return null;
}
const selected = candidates.filter((component, index) => {
if (index === 0) return true;
if (component.area >= primary.area * 0.22) return true;
const expandedPrimary = {
maxX: Math.min(width - 1, primary.maxX + Math.round(width * 0.08)),
maxY: Math.min(height - 1, primary.maxY + Math.round(height * 0.08)),
minX: Math.max(0, primary.minX - Math.round(width * 0.08)),
minY: Math.max(0, primary.minY - Math.round(height * 0.08)),
};
return !(
component.maxX < expandedPrimary.minX ||
component.minX > expandedPrimary.maxX ||
component.maxY < expandedPrimary.minY ||
component.minY > expandedPrimary.maxY
);
});
const selectedMask = new Uint8Array(pixelCount);
for (const component of selected) {
for (let y = component.minY; y <= component.maxY; y += 1) {
const rowOffset = y * width;
for (let x = component.minX; x <= component.maxX; x += 1) {
const index = rowOffset + x;
if (rawMask[index]) selectedMask[index] = 255;
}
}
}
return {
mask: selectedMask,
maskSource: "reference-product",
selectedComponentCount: selected.length,
selectedPixelCount: selected.reduce((sum, component) => sum + component.area, 0),
};
}
async function buildLayoutLockedCompositeImage(params: {
generatedImageUrl: string;
referenceGeometry: { height: number; width: number };
referenceImageUrl: string;
}) {
const sharp = (await import("sharp")).default;
const [referenceSourceBuffer, generatedSourceBuffer] = await Promise.all([
fetchImageBufferFromUrl(params.referenceImageUrl),
fetchImageBufferFromUrl(params.generatedImageUrl),
]);
const targetWidth = params.referenceGeometry.width;
const targetHeight = params.referenceGeometry.height;
const referenceBuffer = await sharp(referenceSourceBuffer, { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, {
fit: "fill",
withoutEnlargement: false,
})
.png()
.toBuffer();
const generatedBuffer = await sharp(generatedSourceBuffer, { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, {
background: { r: 255, g: 255, b: 255, alpha: 1 },
fit: "contain",
position: "centre",
withoutEnlargement: false,
})
.png()
.toBuffer();
const [referenceRaw, generatedRaw] = await Promise.all([
sharp(referenceBuffer, { failOn: "none" })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true }),
sharp(generatedBuffer, { failOn: "none" })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true }),
]);
if (
referenceRaw.info.width !== generatedRaw.info.width ||
referenceRaw.info.height !== generatedRaw.info.height
) {
return null;
}
const selectedMask =
selectReferenceProductMask({
height: referenceRaw.info.height,
reference: referenceRaw.data,
width: referenceRaw.info.width,
}) ||
selectLayoutPatchMask({
generated: generatedRaw.data,
height: referenceRaw.info.height,
reference: referenceRaw.data,
width: referenceRaw.info.width,
});
if (!selectedMask) {
return null;
}
const usesReferenceProductMask = selectedMask.maskSource === "reference-product";
const firstBlurRadius = usesReferenceProductMask ? 14 : 10;
const maskThreshold = usesReferenceProductMask ? 12 : 2;
const pixelCount = targetWidth * targetHeight;
const alphaMask = await sharp(selectedMask.mask, {
raw: {
channels: 1,
height: targetHeight,
width: targetWidth,
},
})
.blur(firstBlurRadius)
.threshold(maskThreshold)
.blur(4)
.extractChannel(0)
.raw()
.toBuffer();
if (alphaMask.length !== pixelCount) {
throw new Error(
`排版替换蒙版通道异常: expected ${pixelCount}, got ${alphaMask.length}`,
);
}
const generatedPatchRaw = Buffer.alloc(pixelCount * 4);
const sourceChannels = generatedRaw.info.channels;
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const sourceOffset = pixelIndex * sourceChannels;
const targetOffset = pixelIndex * 4;
generatedPatchRaw[targetOffset] = generatedRaw.data[sourceOffset] || 0;
generatedPatchRaw[targetOffset + 1] =
generatedRaw.data[sourceOffset + 1] || 0;
generatedPatchRaw[targetOffset + 2] =
generatedRaw.data[sourceOffset + 2] || 0;
generatedPatchRaw[targetOffset + 3] = alphaMask[pixelIndex] || 0;
}
const generatedPatch = await sharp(generatedPatchRaw, {
raw: {
channels: 4,
height: targetHeight,
width: targetWidth,
},
})
.png()
.toBuffer();
const compositeBuffer = await sharp(referenceBuffer, { failOn: "none" })
.composite([{ input: generatedPatch }])
.png()
.toBuffer();
return {
buffer: compositeBuffer,
height: targetHeight,
maskSource: selectedMask.maskSource,
selectedComponentCount: selectedMask.selectedComponentCount,
selectedPixelCount: selectedMask.selectedPixelCount,
width: targetWidth,
};
}
export async function executeCanvasLayoutReplaceTask(params: {
request: CanvasLayoutReplaceTaskRequest;
userId: string;
onRequestDispatched?: () => void | Promise<void>;
}): Promise<CanvasGenerateImageResult> {
const sourceImageUrl = normalizeHttpUrl(params.request.sourceImageUrl);
const referenceImageUrl = normalizeHttpUrl(params.request.referenceImageUrl);
if (!sourceImageUrl) {
throw new Error("缺少当前产品图片");
}
if (!referenceImageUrl) {
throw new Error("缺少排版参考图");
}
const models = await getEnabledAiModelsWithProvider().catch((error) => {
console.warn("[Canvas Layout Replace] Failed to load models:", error);
return undefined;
});
const resolvedModelRef = await resolveDirectReplacementModelRef({
selectedChatModel: params.request.model,
finalModelId: params.request.model,
models,
});
if (!resolvedModelRef || resolvedModelRef === "auto") {
throw new Error("当前风格化未配置可用的生图模型");
}
const replacementStrategy: AgentDetailReplacementStrategy = "product_only";
const referenceLabel =
String(params.request.referenceLabel || "").trim() || "画布排版参考图";
const referenceGeometry =
await resolveReplacementReferenceGeometry(referenceImageUrl);
const planningRuntime = await resolveReplacementScenePlanningRuntime({ models });
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
replacementStrategy,
referenceImageUrl,
referenceLabel,
productImageUrls: [sourceImageUrl],
modelImageUrl: null,
workflowPrompt: null,
planningRuntime,
models,
});
const basePrompt = "仅替换参考详情图中的产品主体";
const replacementPrompt = buildAgentReplacementPrompt({
basePrompt,
modeType: "detail_replacement",
replacementStrategy,
marketplaceProductDetailReference: false,
referenceLabel,
referenceIndex: 0,
totalReferences: 1,
hasModelImage: false,
productImageCount: 1,
originalProductImageCount: 1,
rerunReferenceCount: 0,
templateReferenceCount: 0,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
});
const supplement = normalizeLayoutReplacePromptSupplement(
params.request.supplementalPrompt,
);
const finalPrompt = [
replacementPrompt,
supplement
? `【画布排版替换补充】${supplement}。这段只用于理解新产品材质、颜色、结构或替换细节;不得改写、移动、删除、重排参考图中的任何文字、图标、按钮、标签、分割线、装饰、留白或页面结构。`
: "",
"【最终验收标准】输出是一张与最后一张参考图相同页面比例的单页结果;文字、图标、按钮、标签、分割线、品牌区、留白和信息层级必须视觉上与参考图一致,只允许旧产品主体/产品展示区被当前产品替换。",
]
.filter(Boolean)
.join("\n\n");
const generated = await generateCanvasImage({
prompt: finalPrompt,
displayPrompt: "排版替换结果",
model: resolvedModelRef,
ownerKey: params.userId,
onRequestDispatched: params.onRequestDispatched,
aspectRatio: referenceGeometry?.aspectRatio || "auto",
imageSize: params.request.imageSize || "2K",
imageCount: 1,
promptExtend: false,
referenceImages: [sourceImageUrl, referenceImageUrl],
debugLabel: "canvas-layout-replace",
debugMeta: {
...(params.request.debugMeta || {}),
operation: "layout-replace",
layoutReplaceServerAlgorithm: "agent-detail-replacement/product_only",
sourceNodeId: params.request.sourceNodeId || null,
referenceWidth: referenceGeometry?.width,
referenceHeight: referenceGeometry?.height,
referenceAspectRatio: referenceGeometry?.aspectRatio,
pageKind: replacementExecutionPlan.scenePlan.pageKind,
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
},
});
const firstImage = generated.images[0];
if (!firstImage?.originalUrl) {
throw new Error("排版替换未返回结果图片");
}
let normalizedImage = await normalizeReplacementResultImage({
generatedImageUrl: firstImage.originalUrl,
generatedThumbnailUrl: firstImage.thumbnailUrl,
referenceImageUrl,
referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan,
uploadPrefix: "canvas-layout-replace",
});
if (referenceGeometry) {
const lockedComposite = await buildLayoutLockedCompositeImage({
generatedImageUrl: firstImage.originalUrl,
referenceGeometry,
referenceImageUrl,
}).catch((error) => {
console.warn("[Canvas Layout Replace] Layout locked composite failed:", {
error: error instanceof Error ? error.message : String(error),
referenceImageUrl,
resultImageUrl: firstImage.originalUrl,
});
return null;
});
if (lockedComposite?.buffer) {
const thumbnailBuffer = await createAgentImageThumbnailBuffer(
lockedComposite.buffer,
);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-layout-replace-locked-orig-${suffix}.png`,
bufferToArrayBuffer(lockedComposite.buffer),
),
uploadFile(
`canvas-layout-replace-locked-thumb-${suffix}.jpg`,
bufferToArrayBuffer(thumbnailBuffer),
),
]);
normalizedImage = {
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url || originalUpload.url,
imageSize: `${lockedComposite.width}x${lockedComposite.height}`,
};
console.info("[Canvas Layout Replace] Applied layout locked composite:", {
maskSource: lockedComposite.maskSource,
referenceWidth: lockedComposite.width,
referenceHeight: lockedComposite.height,
selectedComponentCount: lockedComposite.selectedComponentCount,
selectedPixelCount: lockedComposite.selectedPixelCount,
});
}
}
return {
images: [
{
originalUrl: normalizedImage.originalUrl,
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
},
],
model: generated.model,
modelRef: generated.modelRef,
prompt: generated.prompt,
};
}

View File

@ -4,6 +4,7 @@ export default defineConfig({
testDir: "./tests",
testMatch: [
/routes\/canvas-chat-helpers\.test\.ts/,
/routes\/agent-main-ai-template-entry\.test\.ts/,
/routes\/canvas-agent-operations\.test\.ts/,
/routes\/canvas-overlay-density\.test\.ts/,
/routes\/image-message-contracts\.test\.ts/,

View File

@ -408,6 +408,45 @@ export const imageGenerationService = {
}
},
async layoutReplace(params: {
sourceImageUrl: string;
referenceImageUrl: string;
supplementalPrompt?: string;
model?: string;
imageSize?: string;
sourceNodeId?: string;
agentProjectId?: string;
referenceLabel?: string;
debugMeta?: Record<string, unknown>;
}): Promise<
ApiResponse<{
status?: string;
taskId: string;
}>
> {
try {
const response = await fetch('/api/canvas/layout-replace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
if (data.success && data.taskId) {
return {
success: true,
data: {
status: data.status,
taskId: data.taskId,
},
};
}
return { success: false, error: data.error || '排版替换提交失败' };
} catch (error) {
logCatch(error, "layout_replace_submit");
return { success: false, error: '排版替换提交失败' };
}
},
async localEdit(params: {
sourceImageUrl: string;
sourceNodeId?: string;

View File

@ -0,0 +1,63 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { appendAgentProjectResultForSave } from "@/lib/agents/agent-project-save-merge";
import { createEmptyAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("agent main-image AI template entry", () => {
test("keeps the main result-card action and queued generation flow wired", () => {
const workspace = readSource("components/agents/AgentGenerationWorkspaceDarkV2.tsx");
const dialog = readSource("components/agents/AgentAiTemplateDialog.tsx");
const route = readSource("app/api/canvas/layout-replace/route.ts");
const queue = readSource("lib/canvas/image-task-queue.ts");
expect(workspace).toContain(
'const isSceneResultCard = activeResultModeType === "scene_generation"'
);
expect(workspace).toContain('aria-label="AI套模版"');
expect(workspace).toContain("setAiTemplateDialogSourceUrl(card.url!)");
expect(workspace).toContain("<AgentAiTemplateDialog");
expect(workspace).toContain(
'appendResultUrlToCollection("scene_generation", result.imageUrl'
);
expect(dialog).toContain("imageGenerationService.layoutReplace({");
expect(dialog).toContain("agentProjectId: projectId");
expect(dialog).toContain('referenceLabel: "风格化主图 AI 套模版参考图"');
expect(dialog).toContain('operation: "agent-main-ai-template"');
expect(dialog).toContain("pollAiTemplateTask(taskId)");
expect(dialog).toContain("await onResultCreatedRef.current({");
expect(dialog).toContain("max-h-[calc(100dvh-1rem)]");
expect(route).toContain("createCanvasImageTask({");
expect(route).toContain("await enqueueCanvasImageTask(task.id)");
expect(queue).toContain("persistAgentProjectGeneratedResult({");
expect(queue).toContain('modeType: "scene_generation"');
});
test("appends a completed AI template result without dropping existing project results", () => {
const state = createEmptyAgentGenerationProjectPersistedState();
state.resultCollections = {
scene_generation: ["https://example.com/original.png"],
detail_generation: ["https://example.com/detail.png"],
};
const nextState = appendAgentProjectResultForSave({
existingState: state,
modeType: "scene_generation",
imageUrl: "https://example.com/ai-template.png",
});
expect(nextState.resultCollections?.scene_generation).toEqual([
"https://example.com/original.png",
"https://example.com/ai-template.png",
]);
expect(nextState.resultCollections?.detail_generation).toEqual([
"https://example.com/detail.png",
]);
});
});