Compare commits

...

6 Commits

26 changed files with 2150 additions and 52 deletions

5
.gitignore vendored
View File

@ -50,6 +50,7 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/*
/output/
deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br
.temp-packages/
@ -70,6 +71,10 @@ migration-*.txt
artifacts/liblib-*/
analysis/qiaodou-extract/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件
*.model
*.pth

View File

@ -94,7 +94,7 @@ pnpm db:generate
推荐提交前至少运行:
```bash
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
pnpm check:quality
pnpm audit:security

View File

@ -1061,6 +1061,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageRemoveBackground: Scissors,
imageBoxCutout: Scan,
imageLocalEdit: Wand2,
imagePsdTemplateReplace: ImageIcon,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,

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

@ -20,6 +20,7 @@ import {
AlertTriangle,
Check,
Download,
FileImage,
Flag,
GitBranch,
Heart,
@ -715,8 +716,14 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLocalEdit"
);
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
const selectedImagePsdTemplateReplaceEnabled = true;
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
@ -2529,6 +2536,29 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
{selectedImagePsdTemplateReplaceEnabled &&
selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
}
title={selectedImagePsdTemplateReplaceItem.label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={FileImage}
item={selectedImagePsdTemplateReplaceItem}
/>
{selectedImagePsdTemplateReplaceItem.label}
</Button>
) : null}
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"

View File

@ -13,6 +13,7 @@ import {
Download,
Eraser,
Expand,
FileImage,
FlipHorizontal2,
FlipVertical2,
Globe2,
@ -131,10 +132,15 @@ import {
storeCanvasAgentAnnotationEditRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import {
createCanvasPsdTemplateLaunchToken,
saveCanvasPsdTemplateLaunchPayload,
} from "@/lib/canvas/psd-template-launch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -1351,6 +1357,10 @@ export function ImageNode({
);
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveWatermark"
@ -1423,6 +1433,8 @@ export function ImageNode({
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
// 也必须默认展示,避免历史配置把新功能吞掉。
const imageLocalEditEnabled = true;
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
const imagePsdTemplateReplaceEnabled = true;
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageLayerSplit"
@ -1488,6 +1500,7 @@ export function ImageNode({
{ key: "textEdit", enabled: imageTextEditEnabled },
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
{ key: "localEdit", enabled: imageLocalEditEnabled },
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
{ key: "resize", enabled: imageResizeEnabled },
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
@ -1523,6 +1536,7 @@ export function ImageNode({
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
imageEnhanceEnabled ||
imagePsdTemplateReplaceEnabled ||
imageNoteEnabled ||
hasCanvasAgentAnnotations ||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
@ -1681,6 +1695,37 @@ export function ImageNode({
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
useSWR<{
success?: boolean;
templates?: Array<{ brandName?: string | null }>;
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
const availablePsdTemplateBrands = useMemo(
() =>
Array.from(
new Set(
(psdTemplateSummary?.templates || [])
.map((template) => String(template?.brandName || "").trim())
.filter(Boolean)
)
),
[psdTemplateSummary?.templates]
);
useEffect(() => {
if (
psdTemplateDialogOpen &&
!psdTemplateBrandName.trim() &&
availablePsdTemplateBrands.length === 1
) {
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
}
}, [
availablePsdTemplateBrands,
psdTemplateBrandName,
psdTemplateDialogOpen,
]);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
@ -6053,6 +6098,53 @@ export function ImageNode({
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
const handleOpenPsdTemplateReplace = useCallback(() => {
if (!canvasId) {
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
return;
}
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("图片上传完成后再进行模版替换");
return;
}
setPsdTemplateDialogOpen(true);
}, [canvasId, data.src]);
const handleLaunchPsdTemplateReplace = useCallback(() => {
const normalizedBrandName = psdTemplateBrandName.trim();
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
toast.error("当前图片暂时无法进行模版替换");
return;
}
if (!normalizedBrandName) {
toast.error("请先选择或输入品牌名");
return;
}
const token = createCanvasPsdTemplateLaunchToken();
const returnTo =
typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}`
: `/canvas/${canvasId}`;
saveCanvasPsdTemplateLaunchPayload(token, {
brandName: normalizedBrandName,
pendingImages: [
{
id: node.id,
imageUrl: data.src,
title: displayTitle,
},
],
activeImageId: node.id,
returnTo,
templateMode: "main",
createdAt: Date.now(),
});
setPsdTemplateDialogOpen(false);
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
@ -6072,11 +6164,17 @@ export function ImageNode({
if (imageNodeActionRequest.action === "local-edit") {
handleEnterLocalEditMode();
return;
}
if (imageNodeActionRequest.action === "psd-template") {
handleOpenPsdTemplateReplace();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenPsdTemplateReplace,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
@ -6828,6 +6926,10 @@ export function ImageNode({
handleEnterLocalEditMode();
return;
}
if (action === "psd-template") {
handleOpenPsdTemplateReplace();
return;
}
if (action === "scale-adjust") {
handleEnterLocalEditMode(undefined, "scale_adjust");
return;
@ -9593,6 +9695,16 @@ export function ImageNode({
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imagePsdTemplateReplaceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{imageExtendEnabled ? (
<DropdownMenuItem onClick={() => handleAction("extend")}>
<CanvasToolbarIcon
@ -9744,6 +9856,90 @@ export function ImageNode({
prompt={regeneratePrompt}
/>
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileImage className="h-5 w-5" />
模版替换
</DialogTitle>
<DialogDescription>
当前图片会作为替换素材带入 PSD 模版工作台。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
<img
alt={displayTitle}
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
src={buildImageProxyUrl(data.src, { width: 240 })}
/>
<div className="min-w-0 self-center">
<div className="truncate font-medium text-sm">{displayTitle}</div>
<div className="mt-1 text-muted-foreground text-xs">
已选择 1 张画布图片
</div>
</div>
</div>
{isLoadingPsdTemplateSummary ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在读取模版品牌...
</div>
) : availablePsdTemplateBrands.length > 0 ? (
<div className="space-y-2">
<Label>快捷选择</Label>
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
{availablePsdTemplateBrands.map((brand) => {
const active = psdTemplateBrandName.trim() === brand;
return (
<button
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted"
)}
key={brand}
onClick={() => setPsdTemplateBrandName(brand)}
type="button"
>
{brand}
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
<Input
id={`psd-template-brand-${node.id}`}
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
placeholder="选择或输入已配置 PSD 模版的品牌名"
value={psdTemplateBrandName}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
取消
</Button>
<Button
disabled={!psdTemplateBrandName.trim()}
onClick={handleLaunchPsdTemplateReplace}
>
<FileImage className="mr-2 h-4 w-4" />
打开模版替换
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ThreeDGenerationDialog
costLabel={
threedGenerationDialogMode === "world"
@ -10300,7 +10496,7 @@ export function ImageNode({
</Button>
) : null}
{isSelectedImageActionInline("localEdit") ? (
{isSelectedImageActionInline("localEdit") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isSubmittingLocalEdit}
@ -10321,6 +10517,23 @@ export function ImageNode({
</Button>
) : null}
{isSelectedImageActionInline("psdTemplate") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("psd-template")}
title={imagePsdTemplateReplaceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
{imagePsdTemplateReplaceItem.label}
</Button>
) : null}
{isSelectedImageActionInline("removeWatermark") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
@ -10595,6 +10808,16 @@ export function ImageNode({
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("psdTemplate") ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (
<DropdownMenuItem onClick={() => handleAction("watermark")}>
<CanvasToolbarIcon

View File

@ -7,11 +7,11 @@
## 当前分支状态
- 工作分支:`codex/admin-settings-ui-refactor`
- 基线分支:`github/main`
- 当前状态:已同步 `github/main`,`behind 0`;具体 ahead 数以 `pnpm check:branch` 当前输出为准
- 基线分支:当前分支配置的 upstream
- 当前状态:ahead/behind 数以 `pnpm check:branch` 当前输出为准
- 本地工作区:干净
- Dry-run merge:`mergeConflictMarkers: 0`
- 当前 PR 体量:约 `198` 个文件变更;非 merge 提交数以当前 `git log --no-merges github/main..HEAD` 输出为准
- 当前 PR 体量和非 merge 提交数以当前 upstream 对比输出为准
## 主要交付范围
@ -27,7 +27,7 @@
## 已验证证据
```bash
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
pnpm build
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release

View File

@ -5,7 +5,7 @@
## 合并前检查
```bash
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
pnpm lint
pnpm check:logs
@ -46,17 +46,19 @@ SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release
## 分支与 PR 就绪检查
```bash
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
```
`pnpm check:branch` 默认以 `github/main` 为基线,检查当前工作区是否干净、基线 ref 是否存在、当前分支相对 main 的 ahead/behind 状态、受影响文件数量,以及 dry-run merge tree 是否出现冲突标记。它会把 behind main 作为 warning,方便还没决定 rebase/merge 前先看风险;如果发布或合并策略要求必须最新,可使用:
`pnpm check:branch` 默认以当前分支的 upstream 为基线,检查当前工作区是否干净、基线 ref 是否存在、ahead/behind 状态、受影响文件数量,以及 dry-run merge tree 是否出现冲突标记。它会把 behind 作为 warning;如果发布或合并策略要求必须最新,可使用:
```bash
REQUIRE_UP_TO_DATE=true pnpm check:branch
```
如果远端名不同,可以用 `PR_BASE_REF=origin/main pnpm check:branch` 或 `pnpm check:branch origin/main` 覆盖基线。提交前临时验证未提交改动时,可使用 `ALLOW_DIRTY=true pnpm check:branch` 或 `pnpm check:branch --allow-dirty`。
生产主机首次配置时应执行 `git config deployment.branch <生产分支>`。之后 `pnpm check:branch` 会拒绝从其他分支发布。也可以使用 `REQUIRED_BRANCH=<生产分支> pnpm check:branch`做单次校验。
如果需要显式覆盖比较基线,可以用 `PR_BASE_REF=origin/main pnpm check:branch` 或 `pnpm check:branch origin/main`。提交前临时验证未提交改动时,可使用 `ALLOW_DIRTY=true pnpm check:branch` 或 `pnpm check:branch --allow-dirty`。
## CI

View File

@ -6,7 +6,7 @@
```bash
pnpm install --frozen-lockfile
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release
@ -15,7 +15,8 @@ SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release
预期:
- `pnpm check:quality` 通过 lint、日志脱敏门禁、公开交付面品牌治理、CI hygiene、环境变量示例卫生、secret hygiene、package metadata、测试门禁卫生、轻量 helper 回归测试、类型检查、diff hygiene 和 tracked 污染物检查。
- `pnpm check:branch` 确认当前分支相对 `github/main` 没有 dry-run merge 冲突;behind main 会提示为 warning,发布前可用 `REQUIRE_UP_TO_DATE=true` 提升为失败。
- `pnpm check:branch` 默认与当前分支的 upstream 比较,确认没有 dry-run merge 冲突;behind 会提示为 warning,发布前可用 `REQUIRE_UP_TO_DATE=true` 提升为失败。
- 部署主机应设置 `git config deployment.branch <生产分支>`,或在检查时传入 `REQUIRED_BRANCH=<生产分支>`;分支不匹配时检查会直接失败。
- `pnpm audit:security` 输出已审计;如手动运行 Dependency Audit workflow,也应通过。若仍有 critical/high,必须在 [dependency-risk-register.md](dependency-risk-register.md) 中记录 remediation 或风险接受状态。
- `pnpm build` 完成生产构建,验证 Next.js route collection、静态/动态页面边界和构建期 TypeScript。
- `pnpm smoke:routes` 验证 `/` 返回 200。

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,362 @@
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type {
AgentDetailReplacementReferenceItem,
AgentGenerationProjectPersistedState,
AgentGenerationReplacementResultRecord,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
} 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 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,
},
},
};
}
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,
});
return 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)),
});
}
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

@ -220,6 +220,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
label: "局部编辑",
description: "框选多个彩色区域并按区域提示词局部改图",
},
{
key: "imagePsdTemplateReplace",
group: "节点工具栏",
label: "模版替换",
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
},
{
key: "imageRemoveWatermark",
group: "节点工具栏",

View File

@ -207,6 +207,21 @@ export const CANVAS_CAPABILITY_REGISTRY = [
inputSchema: promptArgsSchema,
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
}),
defineCapability({
id: "image.psd_template_replace",
title: "模版替换",
description: "把选中图片带入已配置的 PSD 模版工作台进行素材替换。",
clientAction: "psd-template",
toolbarActionKey: "psdTemplate",
toolbarSettingKey: "imagePsdTemplateReplace",
executionMode: "interactive",
risk: "write",
modelPolicy: "none",
confirmation: "interactive",
inputSchema: emptyArgsSchema,
agent: { exposed: false },
mcp: { exposed: false },
}),
defineCapability({
id: "image.scale_adjust",
title: "比例调整",

View File

@ -9,6 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
"image.text_edit",
"image.box_cutout",
"image.local_edit",
"image.psd_template_replace",
"image.scale_adjust",
"image.remove_watermark",
"image.resize",
@ -38,6 +39,7 @@ export type CanvasCapabilityClientAction =
| "text-edit"
| "box-cutout"
| "local-edit"
| "psd-template"
| "scale-adjust"
| "watermark"
| "resize"
@ -63,6 +65,7 @@ export type SelectedImageToolbarActionKey =
| "textEdit"
| "boxCutout"
| "localEdit"
| "psdTemplate"
| "removeWatermark"
| "resize"
| "multiAngle"

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,634 @@
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;
}): 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,
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/,
@ -12,6 +13,7 @@ export default defineConfig({
/routes\/settings-visual-consistency\.test\.ts/,
/routes\/canvas-scale-adjust-intent\.test\.ts/,
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/canvas-psd-template-entry\.test\.ts/,
/routes\/model-classification\.test\.ts/,
/routes\/model3d-style-pro-regressions\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,

View File

@ -6,7 +6,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
const args = process.argv.slice(2);
const baseRef = process.env.PR_BASE_REF || args.find((arg) => !arg.startsWith("--")) || "github/main";
const currentBranch = git(["branch", "--show-current"]).trim() || "HEAD";
const configuredDeploymentBranch = getOptionalGitConfig("deployment.branch");
const requiredBranch =
String(process.env.REQUIRED_BRANCH || configuredDeploymentBranch || "").trim();
const positionalBaseRef = args.find((arg) => !arg.startsWith("--"));
const upstreamRef = getOptionalUpstreamRef();
const baseRef =
process.env.PR_BASE_REF || positionalBaseRef || upstreamRef || "origin/main";
const requireUpToDate =
process.env.REQUIRE_UP_TO_DATE === "true" || args.includes("--require-up-to-date");
const allowDirty = process.env.ALLOW_DIRTY === "true" || args.includes("--allow-dirty");
@ -14,11 +21,15 @@ const allowDirty = process.env.ALLOW_DIRTY === "true" || args.includes("--allow-
const failures = [];
const warnings = [];
const currentBranch = git(["branch", "--show-current"]).trim() || "HEAD";
const status = git(["status", "--porcelain"]);
if (status.trim() && !allowDirty) {
failures.push("working tree has uncommitted changes; commit, stash, or set ALLOW_DIRTY=true");
}
if (requiredBranch && currentBranch !== requiredBranch) {
failures.push(
`current branch is ${currentBranch}, but deployment requires ${requiredBranch}`
);
}
try {
git(["rev-parse", "--verify", baseRef]);
@ -64,6 +75,8 @@ if (failures.length === 0) {
{
baseRef,
branch: currentBranch,
requiredBranch: requiredBranch || null,
upstreamRef: upstreamRef || null,
mergeBase,
ahead,
behind,
@ -95,6 +108,22 @@ function git(args) {
});
}
function getOptionalGitConfig(key) {
try {
return git(["config", "--get", key]).trim();
} catch {
return "";
}
}
function getOptionalUpstreamRef() {
try {
return git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).trim();
} catch {
return "";
}
}
function runMergeTree(mergeBase, baseRef) {
try {
return git(["merge-tree", mergeBase, "HEAD", baseRef]);

View File

@ -31,6 +31,16 @@ expectIncludes(
"/routes\\/canvas-chat-helpers\\.test\\.ts/",
"canvas helper test target"
);
expectIncludes(
helpersConfig,
"/routes\\/agent-main-ai-template-entry\\.test\\.ts/",
"agent AI template regression target"
);
expectIncludes(
helpersConfig,
"/routes\\/canvas-psd-template-entry\\.test\\.ts/",
"canvas PSD template regression target"
);
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");

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",
]);
});
});

View File

@ -22,6 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
"textEdit",
"boxCutout",
"localEdit",
"psdTemplate",
"removeWatermark",
"resize",
"multiAngle",

View File

@ -4875,6 +4875,18 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("keeps the PSD template replacement action enabled by default", () => {
expect(
getCanvasToolbarItem(
defaultCanvasToolbarConfig,
"imagePsdTemplateReplace"
)
).toMatchObject({
label: "模版替换",
enabled: true,
});
});
test("syncs image notes into structured canvas annotations", () => {
const first = syncCanvasNodeNoteAnnotationMetadata({
metadata: {

View File

@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("canvas PSD template replacement entry", () => {
test("keeps the selected-image toolbar action and launch flow wired", () => {
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
expect(imageNode).toContain(
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imagePsdTemplateReplace"'
);
expect(imageNode).toContain('isSelectedImageActionInline("psdTemplate")');
expect(imageNode).toContain('isSelectedImageActionInMore("psdTemplate")');
expect(imageNode).toContain('handleAction("psd-template")');
expect(imageNode).toContain("handleOpenPsdTemplateReplace");
expect(imageNode).toContain("/psd-template?launch=");
expect(workspace).toContain(
'requestImageNodeAction(selectedImageNodes[0].id, "psd-template")'
);
});
});