Compare commits
6 Commits
46a28f77f0
...
3009727ec1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3009727ec1 | ||
|
|
e2806578c0 | ||
|
|
231650dc00 | ||
|
|
675966a783 | ||
|
|
9ff3dc68ce | ||
|
|
01722af2fb |
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
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}`}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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。
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
362
lib/agents/agent-project-save-merge.ts
Normal file
362
lib/agents/agent-project-save-merge.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -220,6 +220,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
||||
label: "局部编辑",
|
||||
description: "框选多个彩色区域并按区域提示词局部改图",
|
||||
},
|
||||
{
|
||||
key: "imagePsdTemplateReplace",
|
||||
group: "节点工具栏",
|
||||
label: "模版替换",
|
||||
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
|
||||
},
|
||||
{
|
||||
key: "imageRemoveWatermark",
|
||||
group: "节点工具栏",
|
||||
|
||||
@ -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: "比例调整",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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({
|
||||
|
||||
634
lib/canvas/layout-replace-task.ts
Normal file
634
lib/canvas/layout-replace-task.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -22,6 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
|
||||
"textEdit",
|
||||
"boxCutout",
|
||||
"localEdit",
|
||||
"psdTemplate",
|
||||
"removeWatermark",
|
||||
"resize",
|
||||
"multiAngle",
|
||||
|
||||
@ -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: {
|
||||
|
||||
26
tests/routes/canvas-psd-template-entry.test.ts
Normal file
26
tests/routes/canvas-psd-template-entry.test.ts
Normal 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")'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user