From 675966a78305c6ad30abb4745c061c270ccafde9 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 13 Aug 2026 18:18:53 +0800 Subject: [PATCH] feat(agent): add AI template reuse to main results --- app/api/canvas/layout-replace/route.ts | 124 ++++ components/agents/AgentAiTemplateDialog.tsx | 315 +++++++++ .../agents/AgentGenerationWorkspaceDarkV2.tsx | 138 +++- .../agent-project-result-persistence.ts | 67 ++ lib/agents/agent-project-save-merge.ts | 425 ++++++++++++ lib/canvas/image-task-queue.ts | 66 +- lib/canvas/layout-replace-task.ts | 636 ++++++++++++++++++ playwright.helpers.config.ts | 1 + services/canvas/index.ts | 39 ++ .../agent-main-ai-template-entry.test.ts | 63 ++ 10 files changed, 1836 insertions(+), 38 deletions(-) create mode 100644 app/api/canvas/layout-replace/route.ts create mode 100644 components/agents/AgentAiTemplateDialog.tsx create mode 100644 lib/agents/agent-project-result-persistence.ts create mode 100644 lib/agents/agent-project-save-merge.ts create mode 100644 lib/canvas/layout-replace-task.ts create mode 100644 tests/routes/agent-main-ai-template-entry.test.ts diff --git a/app/api/canvas/layout-replace/route.ts b/app/api/canvas/layout-replace/route.ts new file mode 100644 index 000000000..386f979c0 --- /dev/null +++ b/app/api/canvas/layout-replace/route.ts @@ -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, + }); + 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 }, + ); + } +} diff --git a/components/agents/AgentAiTemplateDialog.tsx b/components/agents/AgentAiTemplateDialog.tsx new file mode 100644 index 000000000..649fe82c3 --- /dev/null +++ b/components/agents/AgentAiTemplateDialog.tsx @@ -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; +}; + +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 ( + { + if (!isUploading) { + onOpenChange(nextOpen); + } + }} + > + + + + + AI套模版 + + + 保留参考图的版式、文字和图标,仅将产品主体替换为当前主图。 + + + +
+
+
+ +
+ {isHttpImageUrl(normalizedSourceUrl) ? ( + + ) : null} +
+
+ +
+ +
+ setReferenceImageUrl(event.currentTarget.value)} + placeholder="填写图片链接或上传" + value={referenceImageUrl} + /> + + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + void handleReferenceUpload(file); + }} + type="file" + /> +
+
+ {isUploading ? ( + + ) : isHttpImageUrl(referenceImageUrl) ? ( + + ) : ( + + )} +
+
+
+ +
+ +