feat(agent): add AI template reuse to main results
This commit is contained in:
parent
9ff3dc68ce
commit
675966a783
124
app/api/canvas/layout-replace/route.ts
Normal file
124
app/api/canvas/layout-replace/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
315
components/agents/AgentAiTemplateDialog.tsx
Normal file
315
components/agents/AgentAiTemplateDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}`}
|
||||
|
||||
67
lib/agents/agent-project-result-persistence.ts
Normal file
67
lib/agents/agent-project-result-persistence.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
425
lib/agents/agent-project-save-merge.ts
Normal file
425
lib/agents/agent-project-save-merge.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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({
|
||||
|
||||
636
lib/canvas/layout-replace-task.ts
Normal file
636
lib/canvas/layout-replace-task.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
@ -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;
|
||||
|
||||
63
tests/routes/agent-main-ai-template-entry.test.ts
Normal file
63
tests/routes/agent-main-ai-template-entry.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user