ai-chatbot/components/agents/AgentGenerationWorkspaceDarkV2.tsx
2026-03-22 19:43:31 +08:00

3198 lines
142 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import JSZip from "jszip";
import {
ArrowUp,
ChevronDown,
ChevronUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
UserRound,
X,
Wand2,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { useCanvasStore } from "@/store/canvas";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import {
getAgentRequiredUploadCount,
normalizeAgentPresetModeConfig,
} from "@/lib/agents/preset-mode-config";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentGenerationProjectWorkspaceState,
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
AgentPreset,
AgentTaskDetailReferenceSource,
AgentWorkflowShowcaseConfig,
AgentWorkflowComposerContext,
AgentWorkflowMaterialQueryType,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
import {
createEmptyAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: ReactNode;
brandStorePanel?: ReactNode;
materialSelectionPanel?: ReactNode;
brandModelPanel?: ReactNode;
onClearPreset?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
syncGeneratedResultsToCanvas?: boolean;
agentModeState?: AgentModeWorkspaceState | null;
onAgentModeStateChange?: (next: AgentModeWorkspaceState) => void;
onOpenCanvasEditor?: () => void;
workspaceState?: AgentGenerationProjectWorkspaceState | null;
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
onResultPreviewUrlsChange?: (urls: string[]) => void;
topNavRightSlot?: ReactNode;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
workflowStylePanel?: ReactNode;
}
interface PickedAttachment extends Attachment {}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
interface AgentModeReferenceSearchResponse {
success?: boolean;
data?: {
query: string;
queryType: AgentWorkflowMaterialQueryType;
target: AgentTaskDetailReferenceSource;
matchedSpu?: string | null;
matchedSku?: string | null;
categoryName?: string | null;
shopName?: string | null;
candidateCount: number;
candidates: AgentModeReferenceCandidate[];
selectedCandidateId?: string | null;
references: AgentModeWorkspaceState["detailReplacement"]["references"];
};
error?: {
code?: string;
message?: string;
};
}
function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSource) {
return source === "main" ? "主图" : source === "sku" ? "SKU图" : "详情图";
}
function areImageGenerationParamsEqual(
left: ImageGenerationParams,
right: ImageGenerationParams
) {
return (
left.aspectRatio === right.aspectRatio &&
left.imageSize === right.imageSize &&
(left.imageCount || 1) === (right.imageCount || 1) &&
left.size === right.size &&
left.negativePrompt === right.negativePrompt &&
left.promptExtend === right.promptExtend &&
left.watermark === right.watermark &&
left.seed === right.seed
);
}
function arePickedAttachmentsEqual(
left: Array<PickedAttachment | null>,
right: Array<PickedAttachment | null>
) {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftItem = left[index];
const rightItem = right[index];
if (!leftItem && !rightItem) continue;
if (!leftItem || !rightItem) return false;
if (
leftItem.url !== rightItem.url ||
leftItem.name !== rightItem.name ||
leftItem.contentType !== rightItem.contentType ||
leftItem.assetId !== rightItem.assetId
) {
return false;
}
}
return true;
}
type ToolStepKey =
| "preset"
| "brand-model"
| "materials"
| "prompt"
| "settings";
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
return candidate || fallback;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
return candidate || fallback;
}
}
function normalizeAssistantText(value: string) {
return String(value || "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim();
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
function buildReferenceCardsFromCandidate(params: {
candidate: AgentModeReferenceCandidate | null | undefined;
target: AgentTaskDetailReferenceSource;
productCount?: number;
assignProductIndex?: boolean;
}) {
const candidate = params.candidate;
if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"];
const urls =
params.target === "main"
? candidate.mainImageUrls || []
: params.target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
const targetLabel = `${getTaskDetailReferenceSourceLabel(params.target)}参考图`;
const prefix =
candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套";
return urls.map((url, index) => ({
id: `${candidate.id}-${params.target}-${index + 1}`,
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
imageUrl: url,
candidateId: candidate.id,
sortOrder: index,
assignedProductIndex:
params.assignProductIndex === true && (params.productCount || 0) > 0
? Math.min(index, Math.max((params.productCount || 1) - 1, 0))
: null,
}));
}
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) {
const imageCount =
target === "main"
? candidate.mainImageUrls.length
: target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null;
const timeText =
uploadedAt && !Number.isNaN(uploadedAt.getTime())
? uploadedAt.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: null;
const parts = [
candidate.sceneName || null,
candidate.taskNo || null,
imageCount > 0 ? `${imageCount}张` : null,
timeText,
].filter(Boolean);
return parts.join(" · ");
}
function getCandidatePreviewUrl(
candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource
) {
const targetUrls =
target === "main"
? candidate.mainImageUrls || []
: target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
return (
targetUrls[0] ||
candidate.mainImageUrls[0] ||
candidate.allImageUrls[0] ||
null
);
}
function getCandidateTargetCount(
candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource
) {
return target === "main"
? candidate.mainImageUrls.length
: target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
}
type ReplacementCardStatus = "waiting" | "queued" | "generating" | "completed" | "missing";
function getReplacementCardStatus(params: {
index: number;
resultUrl: string | null;
generatedCount: number;
isGenerating: boolean;
}) : ReplacementCardStatus {
if (params.resultUrl) {
return "completed";
}
if (params.isGenerating) {
return params.index === params.generatedCount ? "generating" : "queued";
}
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
return "missing";
}
return "waiting";
}
function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
switch (status) {
case "completed":
return {
label: "已生成",
className: "border-emerald-400/30 bg-emerald-500/[0.10] text-emerald-200",
};
case "generating":
return {
label: "生成中",
className: "border-sky-400/30 bg-sky-500/[0.10] text-sky-200",
};
case "queued":
return {
label: "排队中",
className: "border-white/12 bg-white/[0.05] text-white/60",
};
case "missing":
return {
label: "未回传",
className: "border-amber-400/30 bg-amber-500/[0.10] text-amber-200",
};
default:
return {
label: "待生成",
className: "border-white/12 bg-white/[0.05] text-white/60",
};
}
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
onBack,
presetPanel,
brandStorePanel,
materialSelectionPanel,
brandModelPanel,
onClearPreset,
siteName,
siteLogo,
siteLogoDark,
syncGeneratedResultsToCanvas = true,
agentModeState = null,
onAgentModeStateChange,
onOpenCanvasEditor,
workspaceState = null,
onWorkspaceStateChange,
onResultPreviewUrlsChange,
topNavRightSlot,
showcaseConfig,
workflowStylePanel,
}: AgentGenerationWorkspaceDarkV2Props) {
const darkSelectContentClass =
"border-white/10 bg-zinc-950 text-white shadow-2xl [--popover:hsl(240_10%_3.9%)] [--popover-foreground:hsl(0_0%_98%)] [--accent:rgba(255,255,255,0.08)] [--accent-foreground:hsl(0_0%_98%)] [--muted:rgba(255,255,255,0.04)] [--muted-foreground:rgba(255,255,255,0.56)]";
const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
[chatId]
);
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore((state) => state.nodes);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const workspaceStateSignatureRef = useRef<string>("");
const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const normalizedIncomingWorkspaceState = useMemo(
() =>
normalizeAgentGenerationProjectPersistedState({
workflowState: {},
workspaceState,
resultImageUrls: [],
}).workspaceState || createEmptyAgentGenerationProjectWorkspaceState(),
[workspaceState]
);
const [input, setInput] = useState(normalizedIncomingWorkspaceState.input);
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>(
normalizedIncomingWorkspaceState.attachments.map((item) =>
item
? {
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/png",
assetId: item.assetId || undefined,
}
: null
)
);
const [isAttachmentSlotExpanded, setIsAttachmentSlotExpanded] = useState(false);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>(
normalizedIncomingWorkspaceState.selectedModelId || "auto"
);
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
}>({
isLoading: false,
lastQuery: null,
});
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
}>({
isLoading: false,
lastQuery: null,
});
const [activeSkuReferenceCardId, setActiveSkuReferenceCardId] = useState<string | null>(null);
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: normalizedIncomingWorkspaceState.imageParams?.aspectRatio || "auto",
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
});
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
displayName: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
imageGenConfig?: any;
}>;
defaultModelId?: string | null;
agentModel?: any;
}>("/api/models", fetcher);
const { data: agentPresetResponse } = useSWR<{
success?: boolean;
data?: AgentPreset;
}>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher);
const selectedAgentPreset = useMemo(
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]);
const referenceImages = workflowContext?.referenceImages || [];
const presetModeConfig = useMemo(
() => normalizeAgentPresetModeConfig(selectedAgentPreset?.defaultParams),
[selectedAgentPreset?.defaultParams]
);
const isSceneGenerationPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "scene_generation";
const isDetailReplacementPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement";
const isSkuReplacementPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "sku_replacement";
const isReplacementPreset = isDetailReplacementPreset || isSkuReplacementPreset;
const productSlotCount =
selectedAgentPreset?.type === "image"
? isSceneGenerationPreset
? presetModeConfig.scene.productImageSlotCount
: isDetailReplacementPreset
? presetModeConfig.detail.productImageSlotCount
: isSkuReplacementPreset
? presetModeConfig.sku.productImageSlotCount
: 2
: 2;
const modelSlotEnabled =
selectedAgentPreset?.type === "image"
? isSceneGenerationPreset
? presetModeConfig.scene.includeModelSlot
: isDetailReplacementPreset
? presetModeConfig.detail.includeModelSlot
: isSkuReplacementPreset
? presetModeConfig.sku.includeModelSlot
: true
: false;
const modelSlotIndex = modelSlotEnabled ? productSlotCount : -1;
const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text");
const agentUploadSlots = useMemo(
() => {
if (!hasAgentUploadSlots) return [];
const nextSlots = Array.from({ length: productSlotCount }, (_, index) => ({
slotIndex: index,
label: `产品图${index + 1}`,
}));
if (modelSlotEnabled) {
nextSlots.push({
slotIndex: modelSlotIndex,
label: "模特图",
});
}
return nextSlots;
},
[hasAgentUploadSlots, modelSlotEnabled, modelSlotIndex, productSlotCount]
);
const requiredUploadCount = hasAgentUploadSlots
? getAgentRequiredUploadCount({
presetType: selectedAgentPreset?.type || "image",
defaultParams: selectedAgentPreset?.defaultParams || null,
fallback: modelSlotEnabled ? 3 : productSlotCount,
})
: 0;
const detailReferenceTarget = presetModeConfig.detail.referenceImageSource;
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget);
const workflowStoreName = String(workflowContext?.store || "").trim();
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
const selectedProductAttachments = attachments.filter((item, index) =>
modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item)
);
const selectedModelAttachment =
modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null;
const filledAttachmentCount = attachments.filter(Boolean).length;
const missingAttachmentCount = Math.max(requiredUploadCount - filledAttachmentCount, 0);
const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`;
const modelAttachmentSummary = modelSlotEnabled
? selectedModelAttachment
? "模特图已带入"
: "模特图待补充"
: null;
const attachmentSummaryReady =
hasAgentUploadSlots && (requiredUploadCount === 0 || filledAttachmentCount >= requiredUploadCount);
const resolvedAgentModeState = agentModeState || localAgentModeState;
const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState;
const detailReplacementState = resolvedAgentModeState.detailReplacement;
const detailReplacementQueryInput = detailReplacementState.query;
const detailReplacementCandidates = detailReplacementState.candidates;
const detailReplacementSelectedCandidateId = detailReplacementState.selectedCandidateId;
const detailReplacementReferenceCards = detailReplacementState.references;
const skuReplacementState = resolvedAgentModeState.skuReplacement;
const skuReplacementQueryInput = skuReplacementState.query;
const skuReplacementQueryType = skuReplacementState.queryType;
const skuReplacementCandidates = skuReplacementState.candidates;
const skuReplacementSelectedCandidateId = skuReplacementState.selectedCandidateId;
const skuReplacementReferenceCards = skuReplacementState.references;
const selectableModels = useMemo(() => {
const imageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen || model.isMultimodal
);
const autoOption = {
id: "auto",
displayName: "自动选择",
providerType: "auto",
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
};
return [autoOption, ...imageModels];
}, [modelsData?.agentModel?.imageGenConfig, modelsData?.models]);
const currentModel = useMemo(
() => selectableModels.find((model) => model.id === selectedModelId) || selectableModels[0],
[selectableModels, selectedModelId]
);
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("preset");
const hasBrandModelStep = Boolean(brandModelPanel) && modelSlotEnabled;
const hasMaterialStep = Boolean(materialSelectionPanel);
const hasWorkflowStyleStep = Boolean(workflowStylePanel) || hasBrandModelStep;
const { messages, setMessages, sendMessage, status } = useChat<ChatMessage>({
id: chatId,
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
const replacementModePayload =
selectedAgentPreset?.type === "image" &&
(isDetailReplacementPreset || isSkuReplacementPreset)
? {
modeType: isDetailReplacementPreset
? ("detail_replacement" as const)
: ("sku_replacement" as const),
query: isDetailReplacementPreset
? detailReplacementQueryInput.trim()
: skuReplacementQueryInput.trim(),
queryType: isDetailReplacementPreset
? ("spu" as const)
: skuReplacementQueryType,
selectedCandidateId: isDetailReplacementPreset
? detailReplacementSelectedCandidateId || null
: skuReplacementSelectedCandidateId || null,
references: (
isDetailReplacementPreset
? detailReplacementReferenceCards
: skuReplacementReferenceCards
)
.filter((item) => Boolean(String(item.imageUrl || "").trim()))
.map((item) => ({
id: item.id,
label: item.label,
imageUrl: String(item.imageUrl || "").trim(),
candidateId: item.candidateId || null,
sortOrder: item.sortOrder ?? 0,
assignedProductIndex:
selectedProductAttachments.length > 0
? Math.min(
item.assignedProductIndex ?? 0,
Math.max(selectedProductAttachments.length - 1, 0)
)
: null,
})),
}
: undefined;
return {
body: {
id: rawChatId,
message: request.messages.at(-1),
hiddenPrompt: hiddenPromptRef.current || undefined,
agentPresetId: selectedAgentPresetId || undefined,
selectedChatModel: selectedModelId,
selectedVisibilityType: "private",
imageParams,
agentModePayload: replacementModePayload,
},
};
},
}),
});
useEffect(() => {
if (!messages.length && historyMessages.length) {
setMessages(historyMessages);
}
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (normalizedIncomingWorkspaceState.selectedModelId) {
const nextModelId = normalizedIncomingWorkspaceState.selectedModelId;
setSelectedModelId((current) =>
current === nextModelId ? current : nextModelId
);
return;
}
if (selectedAgentPreset?.defaultModelId) {
const nextModelId = selectedAgentPreset.defaultModelId;
setSelectedModelId((current) =>
current === nextModelId ? current : nextModelId
);
return;
}
if (modelsData?.defaultModelId) {
const nextModelId = modelsData.defaultModelId;
setSelectedModelId((current) =>
current === nextModelId ? current : nextModelId
);
}
}, [
modelsData?.defaultModelId,
normalizedIncomingWorkspaceState.selectedModelId,
selectedAgentPreset?.defaultModelId,
]);
// 初始化附件
useEffect(() => {
if (!hasAgentUploadSlots) {
setAttachments((current) => (current.length === 0 ? current : []));
return;
}
const nextSlots: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
() => null
);
const primaryImages = referenceImages.filter((image) => image.source === "primary");
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
const brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
image
? { url: image.url, name: image.name || image.label, contentType: image.mimeType || "image/png", assetId: image.assetId || undefined }
: null;
const orderedProductImages = [...primaryImages, ...secondaryImages].slice(0, productSlotCount);
orderedProductImages.forEach((image, index) => {
nextSlots[index] = toAttachment(image);
});
if (modelSlotEnabled && modelSlotIndex >= 0) {
nextSlots[modelSlotIndex] = toAttachment(brandModelImages[0]);
}
setAttachments((current) => {
const result: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] || null
);
for (let index = 0; index < agentUploadSlots.length; index += 1) {
if (!result[index] && nextSlots[index]) {
result[index] = nextSlots[index] as PickedAttachment;
}
}
return arePickedAttachmentsEqual(current, result) ? current : result;
});
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
modelSlotEnabled,
modelSlotIndex,
productSlotCount,
referenceImages,
]);
useEffect(() => {
if (!hasAgentUploadSlots) {
lastSyncedBrandModelUrlRef.current = null;
return;
}
const nextAttachment = selectedBrandModel?.imageUrl
? {
url: selectedBrandModel.imageUrl,
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
contentType: selectedBrandModel.mimeType || "image/png",
assetId: selectedBrandModel.assetId || undefined,
}
: null;
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: Math.max(agentUploadSlots.length, Math.max(modelSlotIndex + 1, 1)) },
(_, index) => current[index] ?? null
);
const currentSlot = modelSlotIndex >= 0 ? slots[modelSlotIndex] ?? null : null;
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
if (
modelSlotIndex >= 0 &&
nextAttachment &&
(!currentSlot || currentSlot.url === previousSyncedUrl)
) {
slots[modelSlotIndex] = nextAttachment;
} else if (modelSlotIndex >= 0 && previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[modelSlotIndex] = null;
}
return arePickedAttachmentsEqual(current, slots) ? current : slots;
});
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
modelSlotIndex,
selectedBrandModel?.assetId,
selectedBrandModel?.brandName,
selectedBrandModel?.displayName,
selectedBrandModel?.id,
selectedBrandModel?.imageUrl,
selectedBrandModel?.mimeType,
]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages
.filter((message) => message.role === "assistant")
.map((message) => {
const textParts = (message.parts || [])
.filter((part: any) => part.type === "text" && part.text)
.map((part: any) => String(part.text));
const imageUrls = Array.from(
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
);
return {
messageId: message.id,
imageUrls,
text: normalizeAssistantText(textParts.join("\n\n")),
};
})
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
);
const latestGeneratedGroup = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex >= 0) {
const latestAssistantAfterLastUser = messages
.slice(lastUserIndex + 1)
.filter((message) => message.role === "assistant")
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group))
.at(-1);
if (latestAssistantAfterLastUser) {
return latestAssistantAfterLastUser;
}
const hasAssistantAfterLastUser = messages
.slice(lastUserIndex + 1)
.some((message) => message.role === "assistant");
if (hasAssistantAfterLastUser) {
return null;
}
}
return generatedGroups[generatedGroups.length - 1] || null;
}, [generatedGroups, messages]);
const latestReplacementResultUrls = latestGeneratedGroup?.imageUrls || [];
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
const detailResultCards = useMemo(
() =>
detailReplacementReferenceCards.map((card, index) => {
const resultUrl = latestReplacementResultUrls[index] || null;
return {
...card,
index,
resultUrl,
status: getReplacementCardStatus({
index,
resultUrl,
generatedCount: latestReplacementResultUrls.length,
isGenerating,
}),
};
}),
[detailReplacementReferenceCards, isGenerating, latestReplacementResultUrls]
);
const skuResultCards = useMemo(
() =>
skuReplacementReferenceCards.map((card, index) => {
const resultUrl = latestReplacementResultUrls[index] || null;
return {
...card,
index,
resultUrl,
status: getReplacementCardStatus({
index,
resultUrl,
generatedCount: latestReplacementResultUrls.length,
isGenerating,
}),
};
}),
[isGenerating, latestReplacementResultUrls, skuReplacementReferenceCards]
);
const sceneResultCardCount = Math.max(
allGeneratedImages.length,
imageParams.imageCount || 1,
1
);
const sceneResultCards = useMemo(
() =>
Array.from({ length: sceneResultCardCount }, (_, index) => ({
id: allGeneratedImages[index] || `scene-result-placeholder-${index}`,
index,
url: allGeneratedImages[index] || null,
})),
[allGeneratedImages, sceneResultCardCount]
);
const attachmentCount = attachments.filter(Boolean).length;
const hasBrandModelSelection = Boolean(
selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel")
);
const hasMaterialSelections = referenceImages.some(
(image) => image.source === "primary" || image.source === "secondary"
);
const hasSatisfiedSlotRequirement = !hasAgentUploadSlots || attachmentCount >= requiredUploadCount;
const slotRequirementMessage =
hasAgentUploadSlots && !hasSatisfiedSlotRequirement
? `当前还缺少 ${missingAttachmentCount} 张输入图,请先在“选择 SPU / SKU”步骤补齐后再继续。`
: null;
const mergedSettingsStepLabel = hasMaterialStep ? "选图与生成" : "生成参数";
const mergedSettingsStepDescription = hasMaterialStep
? "搜索素材、补充要求、确认槽位并开始生成"
: "补充要求并开始生成";
const styleStepBadge = hasMaterialStep ? "03" : "02";
const generateStepBadge =
hasMaterialStep && hasWorkflowStyleStep
? "04"
: hasMaterialStep || hasWorkflowStyleStep
? "03"
: "02";
const toolSteps = useMemo(
() => [
{
key: "preset" as const,
label: "生图模式",
description: "先选择一个模式,再继续后续配置",
icon: Wand2,
disabled: false,
completed: Boolean(selectedAgentPresetId),
optional: false,
},
...(hasBrandModelStep
? [
{
key: "brand-model" as const,
label: "品牌模特",
description: "根据品牌选择模特图",
icon: UserRound,
disabled: !selectedAgentPresetId,
completed: hasBrandModelSelection,
optional: true,
},
]
: []),
{
key: "settings" as const,
label: mergedSettingsStepLabel,
description: mergedSettingsStepDescription,
icon: Settings2,
disabled: !selectedAgentPresetId,
completed:
Boolean(selectedAgentPresetId) &&
(Boolean(input.trim()) || hasMaterialSelections || hasSatisfiedSlotRequirement),
optional: false,
},
],
[
hasBrandModelSelection,
hasBrandModelStep,
hasMaterialSelections,
hasSatisfiedSlotRequirement,
mergedSettingsStepDescription,
mergedSettingsStepLabel,
hasMaterialStep,
input,
selectedAgentPresetId,
]
);
useEffect(() => {
setActiveDetailReferenceCardId((current) =>
detailReplacementReferenceCards.some((card) => card.id === current)
? current
: detailReplacementReferenceCards[0]?.id || null
);
}, [detailReplacementReferenceCards]);
useEffect(() => {
setActiveSkuReferenceCardId((current) =>
skuReplacementReferenceCards.some((card) => card.id === current)
? current
: skuReplacementReferenceCards[0]?.id || null
);
}, [skuReplacementReferenceCards]);
const activeToolStepIndex = Math.max(
toolSteps.findIndex((step) => step.key === activeToolStep),
0
);
const activeToolStepMeta = toolSteps[activeToolStepIndex] || toolSteps[0];
const previousToolStep = activeToolStepIndex > 0 ? toolSteps[activeToolStepIndex - 1] : null;
const nextToolStep =
activeToolStepIndex < toolSteps.length - 1 ? toolSteps[activeToolStepIndex + 1] : null;
useEffect(() => {
const incomingSignature = JSON.stringify(normalizedIncomingWorkspaceState);
if (incomingSignature === workspaceStateSignatureRef.current) {
return;
}
workspaceStateSignatureRef.current = incomingSignature;
setInput(normalizedIncomingWorkspaceState.input);
setAttachments((current) => {
const nextAttachments = normalizedIncomingWorkspaceState.attachments.map((item) =>
item
? {
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/png",
assetId: item.assetId || undefined,
}
: null
);
return arePickedAttachmentsEqual(current, nextAttachments) ? current : nextAttachments;
});
setSelectedModelId((current) => {
const nextModelId = normalizedIncomingWorkspaceState.selectedModelId || "auto";
return current === nextModelId ? current : nextModelId;
});
setImageParams((current) => {
const nextParams = {
aspectRatio: normalizedIncomingWorkspaceState.imageParams?.aspectRatio || "auto",
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
};
return areImageGenerationParamsEqual(current, nextParams) ? current : nextParams;
});
}, [normalizedIncomingWorkspaceState]);
useEffect(() => {
const nextWorkspaceState = {
input,
attachments: attachments.map((item) =>
item
? {
url: item.url,
name: item.name || null,
contentType: item.contentType || null,
assetId: item.assetId || null,
}
: null
),
selectedModelId,
imageParams: {
aspectRatio: imageParams.aspectRatio,
imageSize: imageParams.imageSize,
imageCount: imageParams.imageCount,
},
};
workspaceStateSignatureRef.current = JSON.stringify(nextWorkspaceState);
onWorkspaceStateChange?.(nextWorkspaceState);
}, [
attachments,
imageParams.aspectRatio,
imageParams.imageCount,
imageParams.imageSize,
input,
onWorkspaceStateChange,
selectedModelId,
]);
useEffect(() => {
onResultPreviewUrlsChange?.(
Array.from(new Set(isReplacementPreset ? latestReplacementResultUrls : allGeneratedImages))
);
}, [allGeneratedImages, isReplacementPreset, latestReplacementResultUrls, onResultPreviewUrlsChange]);
useEffect(() => {
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
if (!selectedAgentPresetId || !hasAgentUploadSlots) {
setIsAttachmentSlotExpanded(false);
}
}, [hasAgentUploadSlots, selectedAgentPresetId]);
useEffect(() => {
if (!selectedAgentPresetId) {
setActiveToolStep("preset");
return;
}
setActiveToolStep((current) => {
if (
current !== "preset" &&
current !== "materials" &&
current !== "prompt"
) {
return current;
}
if (hasBrandModelStep) return "brand-model";
return "settings";
});
}, [
hasBrandModelStep,
selectedAgentPresetId,
]);
useEffect(() => {
if (!toolSteps.some((step) => step.key === activeToolStep)) {
setActiveToolStep(selectedAgentPresetId ? "settings" : "preset");
}
}, [activeToolStep, selectedAgentPresetId, toolSteps]);
useEffect(() => {
if (!syncGeneratedResultsToCanvas) {
return;
}
const latestUrls = latestGeneratedGroup?.imageUrls || [];
if (latestUrls.length === 0) return;
latestUrls.forEach(async (url, index) => {
if (!url || generatedImageKeysRef.current.has(url)) return;
generatedImageKeysRef.current.add(url);
try {
const dimensions = await getImageDimensions(url);
const nodeIndex = nodes.length + index;
const spacing = 20;
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "生成结果",
prompt: latestGeneratedGroup?.text || "生成结果",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error);
}
});
}, [addNode, latestGeneratedGroup, nodes.length, syncGeneratedResultsToCanvas]);
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
return slots;
});
}, [agentUploadSlots.length]);
const handleSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
setSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || "上传失败");
}
updateAttachmentAtSlot(slotIndex, {
url: data.url,
name: file.name,
contentType: file.type || "image/jpeg",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
});
toast.success(`${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setSlotUploading(null);
event.currentTarget.value = "";
}
},
[agentUploadSlots, updateAttachmentAtSlot]
);
const handleBatchDownloadImages = useCallback(async (params: {
imageUrls: string[];
zipName: string;
scope: "scene" | "detail" | "sku";
}) => {
const normalizedUrls = Array.from(
new Set(
(params.imageUrls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
if (normalizedUrls.length === 0) {
toast.error("当前没有可下载的结果图");
return;
}
setBatchDownloadState(params.scope);
try {
const zip = new JSZip();
let successCount = 0;
for (const [index, imageUrl] of normalizedUrls.entries()) {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const fallbackName = `${params.zipName}-${String(index + 1).padStart(2, "0")}.png`;
zip.file(buildDownloadFilename(imageUrl) || fallbackName, blob);
successCount += 1;
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Batch download item failed:", {
imageUrl,
error,
});
}
}
if (successCount === 0) {
throw new Error("没有成功下载到任何图片");
}
const blob = await zip.generateAsync({ type: "blob" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${params.zipName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
toast.success(`已打包下载 ${successCount} 张图片`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量下载失败");
} finally {
setBatchDownloadState(null);
}
}, []);
const handleSendResultToCanvasTextEdit = useCallback((params: {
imageUrl: string;
title: string;
}) => {
const rawUrl = String(params.imageUrl || "").trim();
if (!rawUrl) {
toast.error("结果图地址无效");
return;
}
const previewTitle = String(params.title || "").trim() || "待改字结果";
const nodeIndex = nodes.length;
const nodeId = addNode({
type: "image",
position: {
x: 160 + (nodeIndex % 3) * 340,
y: 140 + Math.floor(nodeIndex / 3) * 340,
},
size: { width: 320, height: 320 },
data: {
src: rawUrl,
status: "completed",
title: previewTitle,
prompt: previewTitle,
},
connections: [],
});
selectNodes([nodeId]);
requestImageNodeAction(nodeId, "text-edit");
onOpenCanvasEditor?.();
toast.success("已送入画布,可继续改字");
}, [addNode, nodes.length, onOpenCanvasEditor, requestImageNodeAction, selectNodes]);
const handleSend = useCallback(() => {
if (!selectedAgentPresetId) {
toast.error("请先选择生图模式");
return;
}
if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return;
}
const uploadedAttachmentCount = attachments.filter(Boolean).length;
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
return;
}
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
attachments.forEach((attachment) => {
if (!attachment) return;
parts.push({ type: "file", mediaType: attachment.contentType || "image/jpeg", name: attachment.name || "参考图", url: attachment.url, assetId: attachment.assetId });
});
let textContent = input.trim();
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
}
if (textContent) {
parts.push({ type: "text", text: textContent });
}
if (parts.length === 0) {
toast.error("请至少补充要求或上传图片");
return;
}
hiddenPromptRef.current = workflowContext?.promptText
? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
: null;
sendMessage({ role: "user", parts });
setInput("");
}, [
attachments,
detailReplacementReferenceCards.length,
hasAgentUploadSlots,
input,
isDetailReplacementPreset,
isReplacementPreset,
isSkuReplacementPreset,
requiredUploadCount,
selectedAgentPreset,
selectedAgentPresetId,
sendMessage,
skuReplacementReferenceCards.length,
workflowContext?.promptText,
workflowContext?.summaryText,
]);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
const handleToolStepChange = useCallback(
(nextKey: ToolStepKey) => {
const nextStep = toolSteps.find((step) => step.key === nextKey);
if (!nextStep) return;
if (nextStep.disabled) {
toast.error("请先选择生图模式");
return;
}
setActiveToolStep(nextKey);
},
[toolSteps]
);
const scrollToSection = useCallback((sectionId: string) => {
if (
(sectionId === "agent-anchor-generate" || sectionId === "agent-anchor-results") &&
hasAgentUploadSlots &&
!hasSatisfiedSlotRequirement
) {
toast.error(slotRequirementMessage || "请先补齐输入图后再继续");
const fallbackTarget = hasMaterialStep ? "agent-anchor-materials" : "agent-anchor-generate";
const fallbackElement = typeof document !== "undefined"
? document.getElementById(fallbackTarget)
: null;
fallbackElement?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (typeof document === "undefined") {
return;
}
const target = document.getElementById(sectionId);
if (!target) {
return;
}
target.scrollIntoView({ behavior: "smooth", block: "start" });
}, [hasAgentUploadSlots, hasMaterialStep, hasSatisfiedSlotRequirement, slotRequirementMessage]);
const updateDetailReplacementState = useCallback(
(
updater:
| Partial<AgentModeWorkspaceState["detailReplacement"]>
| ((current: AgentModeWorkspaceState["detailReplacement"]) => AgentModeWorkspaceState["detailReplacement"])
) => {
const current = resolvedAgentModeState.detailReplacement;
const nextDetailReplacement =
typeof updater === "function" ? updater(current) : { ...current, ...updater };
emitAgentModeState({
...resolvedAgentModeState,
detailReplacement: nextDetailReplacement,
});
},
[emitAgentModeState, resolvedAgentModeState]
);
const updateSkuReplacementState = useCallback(
(
updater:
| Partial<AgentModeWorkspaceState["skuReplacement"]>
| ((current: AgentModeWorkspaceState["skuReplacement"]) => AgentModeWorkspaceState["skuReplacement"])
) => {
const current = resolvedAgentModeState.skuReplacement;
const nextSkuReplacement =
typeof updater === "function" ? updater(current) : { ...current, ...updater };
emitAgentModeState({
...resolvedAgentModeState,
skuReplacement: nextSkuReplacement,
});
},
[emitAgentModeState, resolvedAgentModeState]
);
const handleDetailReplacementSearch = useCallback(async () => {
const normalized = detailReplacementQueryInput.trim().toUpperCase();
if (!workflowStoreName) {
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
return;
}
if (!normalized) {
toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
return;
}
setDetailReplacementSearchState({
isLoading: true,
lastQuery: normalized,
});
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: "spu",
target: detailReferenceTarget,
});
if (workflowStoreName) {
searchParams.set("storeName", workflowStoreName);
}
const response = await fetch(
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || `${detailReferenceSourceLabel}参考图查询失败`);
}
updateDetailReplacementState({
query: normalized,
queryType: "spu",
candidates: result.data.candidates || [],
selectedCandidateId:
result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null,
references: result.data.references || [],
});
setDetailReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.success(`已载入 ${result.data.references?.length || 0} 张${detailReferenceSourceLabel}参考图`);
} catch (error) {
setDetailReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`);
}
}, [
detailReferenceSourceLabel,
detailReferenceTarget,
detailReplacementQueryInput,
updateDetailReplacementState,
workflowStoreName,
]);
const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => {
updateDetailReplacementState((current) => {
const nextCandidate =
current.candidates.find((candidate) => candidate.id === candidateId) || null;
return {
...current,
selectedCandidateId: candidateId,
references: buildReferenceCardsFromCandidate({
candidate: nextCandidate,
target: detailReferenceTarget,
productCount: selectedProductAttachments.length,
}),
};
});
}, [detailReferenceTarget, selectedProductAttachments.length, updateDetailReplacementState]);
const handleRemoveDetailReferenceCard = useCallback((cardId: string) => {
updateDetailReplacementState((current) => ({
...current,
references: current.references.filter((card) => card.id !== cardId),
}));
}, [updateDetailReplacementState]);
const handleClearDetailReferenceCards = useCallback(() => {
updateDetailReplacementState((current) => ({
...current,
candidates: [],
selectedCandidateId: null,
references: [],
}));
}, [updateDetailReplacementState]);
const handleSkuReplacementSearch = useCallback(async () => {
const normalized = skuReplacementQueryInput.trim().toUpperCase();
if (!workflowStoreName) {
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
return;
}
if (!normalized) {
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`);
return;
}
setSkuReplacementSearchState({
isLoading: true,
lastQuery: normalized,
});
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: skuReplacementQueryType,
target: skuReferenceTarget,
});
if (workflowStoreName) {
searchParams.set("storeName", workflowStoreName);
}
const response = await fetch(
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || `${skuReferenceSourceLabel}参考图查询失败`);
}
updateSkuReplacementState({
query: normalized,
queryType: result.data.queryType || skuReplacementQueryType,
candidates: result.data.candidates || [],
selectedCandidateId:
result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null,
references:
(result.data.references || []).map((item, index) => ({
...item,
assignedProductIndex:
selectedProductAttachments.length > 0
? Math.min(index, Math.max(selectedProductAttachments.length - 1, 0))
: null,
})) || [],
});
setSkuReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.success(`已载入 ${result.data.references?.length || 0} 张${skuReferenceSourceLabel}参考图`);
} catch (error) {
setSkuReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.error(error instanceof Error ? error.message : `${skuReferenceSourceLabel}参考图查询失败`);
}
}, [
selectedProductAttachments.length,
skuReferenceSourceLabel,
skuReferenceTarget,
skuReplacementQueryInput,
skuReplacementQueryType,
updateSkuReplacementState,
workflowStoreName,
]);
const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => {
updateSkuReplacementState((current) => {
const nextCandidate =
current.candidates.find((candidate) => candidate.id === candidateId) || null;
return {
...current,
selectedCandidateId: candidateId,
references: buildReferenceCardsFromCandidate({
candidate: nextCandidate,
target: skuReferenceTarget,
productCount: selectedProductAttachments.length,
assignProductIndex: true,
}),
};
});
}, [selectedProductAttachments.length, skuReferenceTarget, updateSkuReplacementState]);
const handleAssignSkuReferenceProduct = useCallback((cardId: string, productIndex: number) => {
updateSkuReplacementState((current) => ({
...current,
references: current.references.map((card) =>
card.id === cardId
? {
...card,
assignedProductIndex: productIndex,
}
: card
),
}));
}, [updateSkuReplacementState]);
const handleRemoveSkuReferenceCard = useCallback((cardId: string) => {
updateSkuReplacementState((current) => ({
...current,
references: current.references.filter((card) => card.id !== cardId),
}));
}, [updateSkuReplacementState]);
const handleClearSkuReferenceCards = useCallback(() => {
updateSkuReplacementState((current) => ({
...current,
candidates: [],
selectedCandidateId: null,
references: [],
}));
}, [updateSkuReplacementState]);
const attachmentSlotPanel = (
<Collapsible
open={isAttachmentSlotExpanded}
onOpenChange={setIsAttachmentSlotExpanded}
>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
输入图槽位
</div>
{agentUploadSlots.length > 0 ? (
<>
<div className="flex flex-wrap gap-2">
<span
className={cn(
"rounded-full border px-3 py-1 text-xs",
attachmentSummaryReady
? "border-emerald-400/25 bg-emerald-500/[0.10] text-emerald-100"
: "border-amber-400/25 bg-amber-500/[0.10] text-amber-100"
)}
>
{productAttachmentSummary}
</span>
{modelAttachmentSummary ? (
<span
className={cn(
"rounded-full border px-3 py-1 text-xs",
selectedModelAttachment
? "border-emerald-400/25 bg-emerald-500/[0.10] text-emerald-100"
: "border-amber-400/25 bg-amber-500/[0.10] text-amber-100"
)}
>
{modelAttachmentSummary}
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-white/60">
共 {agentUploadSlots.length} 个输入槽位
</span>
</div>
<p className="text-xs leading-6 text-white/55">
{missingAttachmentCount > 0
? `当前还需补充 ${missingAttachmentCount} 张输入素材,展开后可继续检查或替换。`
: "素材已自动带入并准备完成,默认折叠显示;需要时再展开检查或替换即可。"}
</p>
</>
) : (
<p className="text-xs leading-6 text-white/45">
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</p>
)}
</div>
{agentUploadSlots.length > 0 ? (
<CollapsibleTrigger asChild>
<Button
type="button"
variant="outline"
className="border-white/12 bg-black/25 text-white/82 hover:border-white/22 hover:bg-white/[0.06] hover:text-white"
>
{isAttachmentSlotExpanded ? "收起素材槽位" : "检查/替换素材"}
{isAttachmentSlotExpanded ? (
<ChevronUp className="ml-2 h-4 w-4" />
) : (
<ChevronDown className="ml-2 h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
) : null}
</div>
{agentUploadSlots.length > 0 ? (
<CollapsibleContent className="mt-4 space-y-4">
<div className="rounded-[20px] border border-emerald-400/15 bg-emerald-500/[0.06] px-4 py-3 text-xs leading-6 text-emerald-100/80">
上一步已选的白底图和品牌模特图会自动带入对应槽位,这里只保留最终检查与单张上传替换,不再重复展示已选素材列表。
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{agentUploadSlots.map((slot, index) => {
return (
<motion.div
key={slot.label}
whileHover={{ scale: 1.02 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-[24px] transition-all",
attachments[index]
? "ring-2 ring-emerald-400/45"
: "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35"
)}
>
<input
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-3 py-2 text-xs font-medium text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white transition-opacity hover:bg-white/30"
onClick={() => updateAttachmentAtSlot(index, null)}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-white/55">
{slotUploading === index ? (
<Loader2 className="h-6 w-6 animate-spin text-emerald-400" />
) : (
<>
<Upload className="h-6 w-6" />
<span className="text-sm font-medium text-white/72">{slot.label}</span>
</>
)}
</div>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-2 p-2">
<button
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
className="rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/80 transition-colors hover:border-white/20 hover:text-white"
>
{attachments[index] ? "更换" : "上传"}
</button>
</div>
</motion.div>
);
})}
</div>
</CollapsibleContent>
) : (
<div className="mt-4 flex min-h-[132px] flex-col items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</div>
)}
</div>
</Collapsible>
);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-black">
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
onBack={onBack}
activeKey="agent"
rightSlot={topNavRightSlot || <div className="w-10" />}
/>
{/* Main Content */}
<main className="flex-1 overflow-y-auto overscroll-contain pt-16">
<div className="mx-auto flex w-full max-w-[1480px] flex-col gap-6 px-6 py-6 pb-24 lg:px-8 xl:px-10">
{showcaseConfig ? (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.04 }}
>
<AgentWorkflowShowcaseBanner
config={showcaseConfig}
actionLabel="开始选择模式"
secondaryActionLabel={allGeneratedImages.length > 0 ? "查看结果预览" : undefined}
onActionClick={() => scrollToSection("agent-anchor-mode")}
onSecondaryActionClick={() => scrollToSection("agent-anchor-results")}
/>
</motion.section>
) : null}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06 }}
className="rounded-[28px] border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
>
<div className="space-y-6">
<section id="agent-anchor-mode" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 flex flex-col gap-4 border-b border-white/10 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
01
</span>
<span className="text-sm font-semibold text-white">选择生图模式</span>
</div>
<p className="text-sm text-white/45">
先确定本次使用的模式,并完成品牌和店铺选择,后续的商品图、品牌模特、参考图和结果展示都会随之联动。
</p>
{isReplacementPreset ? (
<p className="text-xs text-amber-300/80">
{isDetailReplacementPreset
? "当前模板属于详情替换模式,结果区会按参考图顺序逐屏展示。"
: "当前模板属于 SKU 替换模式,结果区会按参考图顺序一对一展示。"}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedAgentPreset ? (
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-black/20 px-3 py-2">
{selectedAgentPreset.avatarUrl ? (
<img
src={selectedAgentPreset.avatarUrl}
alt={selectedAgentPreset.name}
className="h-10 w-10 rounded-xl object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/18">
<Wand2 className="h-5 w-5 text-emerald-300" />
</div>
)}
<div>
<div className="text-sm font-medium text-white">{selectedAgentPreset.name}</div>
<div className="text-xs text-white/40">
{selectedAgentPreset.type === "image" ? "生图模式" : "视频模式"}
</div>
</div>
</div>
) : null}
{selectedAgentPreset && onClearPreset ? (
<button
type="button"
onClick={onClearPreset}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-emerald-300 transition-colors hover:border-emerald-300/35 hover:text-emerald-200"
>
更换生图模式
</button>
) : null}
</div>
</div>
{presetPanel ? (
<div className="agent-dark-scrollbar max-h-[760px] overflow-y-auto pr-1">
{presetPanel}
</div>
) : (
<div className="flex min-h-[220px] items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 text-sm text-white/45">
暂无可用的生图模式配置。
</div>
)}
{brandStorePanel ? (
<div className="mt-4 border-t border-white/10 pt-4">
{brandStorePanel}
</div>
) : null}
</section>
{hasMaterialStep ? (
<section id="agent-anchor-materials" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
02
</span>
<span className="text-sm font-semibold text-white">选择 SPU / SKU</span>
</div>
<p className="text-sm text-white/45">
先搜索商品白底图与搭配图,再继续完成风格步骤和生成参数配置。
</p>
</div>
{materialSelectionPanel}
</section>
) : null}
{hasWorkflowStyleStep ? (
<section id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
{styleStepBadge}
</span>
<span className="text-sm font-semibold text-white">风格步骤卡片</span>
</div>
<p className="text-sm text-white/45">
在同一页完成风格卡片、品牌模特和 Prompt 组合,不再跳转第二个页面。
</p>
</div>
<div className="space-y-4">
{hasBrandModelStep ? brandModelPanel : null}
{workflowStylePanel}
</div>
</section>
) : null}
<section id="agent-anchor-generate" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
{generateStepBadge}
</span>
<span className="text-sm font-semibold text-white">生图与结果预览</span>
</div>
<p className="text-sm text-white/45">
确认补充要求、输入图槽位和生成参数后直接开始生成,结果会显示在当前页底部预览区。
</p>
</div>
<div className="space-y-4">
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Sparkles className="h-4 w-4 text-emerald-300" />
补充要求
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
className="min-h-[160px] resize-none rounded-[24px] border-white/12 bg-black text-sm text-white placeholder:text-white/20 focus:border-emerald-400/30"
/>
</div>
{hasAgentUploadSlots && hasMaterialStep && hasSatisfiedSlotRequirement ? (
<div className="flex flex-col gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium text-white">输入图已在上一步确认</div>
<div className="text-xs text-white/45">
{productAttachmentSummary}
{modelAttachmentSummary ? `,${modelAttachmentSummary}` : ""}
。如需微调,再手动展开编辑即可。
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => setIsAttachmentSlotExpanded(true)}
className="border-white/12 bg-black/25 text-white/82 hover:border-white/22 hover:bg-white/[0.06] hover:text-white"
>
手动调整输入图
</Button>
</div>
) : null}
{hasAgentUploadSlots && isAttachmentSlotExpanded ? attachmentSlotPanel : null}
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Settings2 className="h-4 w-4 text-emerald-300" />
生成参数
</h3>
<div className="space-y-3">
{isReplacementPreset ? (
<div className="rounded-[24px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-sm text-amber-100/90">
{isDetailReplacementPreset
? "详情替换会按当前参考图顺序逐张执行,结果将在下方结果区一一回显。"
: "SKU替换会按当前参考图顺序执行一对一替换,结果将在下方结果区一一回显。"}
</div>
) : null}
{shouldAllowModelOverride ? (
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-[24px] border-white/12 bg-black text-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{selectableModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white focus:bg-white/10 focus:text-white"
>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
当前模板已锁定模型:
<span className="ml-2 font-medium text-white">
{selectedAgentPreset?.defaultModel?.displayName || currentModel?.displayName || "未配置"}
</span>
</div>
)}
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={setImageParams}
inline
inlineTheme="dark"
showInlineDivider={false}
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
/>
<Button
onClick={handleSend}
disabled={
isGenerating ||
!selectedAgentPresetId ||
(hasAgentUploadSlots && !hasSatisfiedSlotRequirement)
}
className={cn(
"h-11 w-full gap-2 rounded-[24px]",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_24px_rgba(16,185,129,0.24)]",
"disabled:opacity-30"
)}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isReplacementPreset ? "批量替换中..." : "生成中..."}
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
{isReplacementPreset ? "开始批量替换" : "开始生成"}
</>
)}
</Button>
{slotRequirementMessage ? (
<div className="rounded-[18px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-xs leading-6 text-amber-100/90">
{slotRequirementMessage}
</div>
) : null}
</div>
</div>
</div>
</section>
</div>
</motion.section>
<motion.section
id="agent-anchor-results"
className="relative flex min-h-[620px] flex-col overflow-hidden rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.12 }}
>
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white">结果预览</div>
<div className="text-xs text-white/40">
{isDetailReplacementPreset
? "详情替换结果会按参考图顺序纵向展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: isSkuReplacementPreset
? "SKU替换结果会按参考图顺序一对一展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{isDetailReplacementPreset && latestReplacementResultUrls.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: latestReplacementResultUrls,
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
scope: "detail",
})
}
disabled={batchDownloadState === "detail"}
className="rounded-full border border-emerald-400/30 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/[0.14] disabled:opacity-50"
>
{batchDownloadState === "detail" ? "打包中..." : "批量下载"}
</button>
) : null}
{isSkuReplacementPreset && latestReplacementResultUrls.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: latestReplacementResultUrls,
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
scope: "sku",
})
}
disabled={batchDownloadState === "sku"}
className="rounded-full border border-emerald-400/30 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/[0.14] disabled:opacity-50"
>
{batchDownloadState === "sku" ? "打包中..." : "批量下载"}
</button>
) : null}
{!isReplacementPreset && allGeneratedImages.length > 1 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: allGeneratedImages,
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
scope: "scene",
})
}
disabled={batchDownloadState === "scene"}
className="rounded-full border border-emerald-400/30 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/[0.14] disabled:opacity-50"
>
{batchDownloadState === "scene" ? "打包中..." : "批量下载"}
</button>
) : null}
{isDetailReplacementPreset ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {detailReplacementReferenceCards.length} 屏
</div>
) : isSkuReplacementPreset ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {skuReplacementReferenceCards.length} 组
</div>
) : allGeneratedImages.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {allGeneratedImages.length} 张
</div>
) : null}
</div>
</div>
{isDetailReplacementPreset ? (
<div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
<div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">输入图</div>
<div className="text-xs text-white/40">
选中的白底图会作为详情替换的输入图组
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{selectedProductAttachments.length} 张
</div>
</div>
<div className="space-y-3">
{selectedProductAttachments.length > 0 ? (
selectedProductAttachments.map((attachment, index) => (
<div
key={`${attachment?.url || "input"}-${index}`}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(attachment!.url, 400)}
alt={attachment!.name || `输入图 ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
<div className="border-t border-white/10 px-3 py-2">
<div className="text-xs font-medium text-white/80">
输入图 {index + 1}
</div>
<div className="truncate text-[11px] text-white/40">
{attachment?.name || "已选择白底图"}
</div>
</div>
</div>
))
) : (
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
请先在当前“选图与生成”页的输入图槽位中确认用于详情替换的白底输入图。
</div>
)}
{selectedModelAttachment ? (
<div className="rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06] p-3">
<div className="text-xs font-medium text-emerald-200">模特图</div>
<div className="mt-1 truncate text-[11px] text-emerald-100/70">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</div>
) : null}
</div>
</div>
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">{detailReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40">
仅支持按 SPU 查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{detailReplacementReferenceCards.length} 张
</div>
</div>
<div className="mb-3">
<div className="inline-flex rounded-full border border-emerald-400/35 bg-emerald-500/[0.10] px-3 py-1.5 text-xs text-emerald-200">
按SPU查询
</div>
</div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<Textarea
value={detailReplacementQueryInput}
onChange={(e) =>
updateDetailReplacementState({
query: e.target.value,
queryType: "spu",
})
}
placeholder={`输入 SPU,查询任务整套中的${detailReferenceSourceLabel}参考图`}
className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
<Button
type="button"
onClick={handleDetailReplacementSearch}
disabled={detailReplacementSearchState.isLoading}
className="h-auto min-h-[68px] rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{detailReplacementSearchState.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
查询中
</>
) : (
`查询${detailReferenceSourceLabel}`
)}
</Button>
{detailReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={handleClearDetailReferenceCards}
className="h-auto min-h-[68px] rounded-[22px] border-white/10 bg-white/[0.03] px-5 text-sm text-white hover:bg-white/[0.08]"
>
清空参考图
</Button>
) : null}
</div>
{(detailReplacementSearchState.lastQuery || detailReplacementQueryInput.trim()) ? (
<div className="mb-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
SPU /
{detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()}
{workflowStoreName ? (
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${detailReferenceSourceLabel}参考图接口`}
</span>
</div>
) : null}
{detailReplacementCandidates.length > 0 ? (
<div className="mb-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs text-white/42">
候选整套素材 {detailReplacementCandidates.length} 套
</div>
<div className="text-[11px] text-emerald-200/70">
默认载入最新一套,可随时切换
</div>
</div>
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{detailReplacementCandidates.map((candidate) => {
const isSelected = detailReplacementSelectedCandidateId === candidate.id;
const previewUrl = getCandidatePreviewUrl(candidate, detailReferenceTarget);
return (
<button
key={candidate.id}
type="button"
onClick={() => handleSelectDetailReplacementCandidate(candidate.id)}
className={cn(
"min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors",
isSelected
? "border-emerald-400/45 bg-emerald-500/[0.10]"
: "border-white/10 bg-white/[0.03] hover:border-white/18"
)}
>
<div className="relative min-h-[180px] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.18),transparent_58%),linear-gradient(180deg,#0b0d0f_0%,#11161a_100%)]">
{previewUrl ? (
<img
src={buildPreviewImageUrl(previewUrl, 640)}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
className="block h-auto w-full"
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
{getCandidateTargetCount(candidate, detailReferenceTarget)} 张{detailReferenceSourceLabel}
</div>
{isSelected ? (
<div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前使用
</div>
) : null}
</div>
<div className="space-y-2 px-3 py-3">
<div className="truncate text-sm font-medium text-white">
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div>
<div className="line-clamp-2 text-[11px] text-white/42">
{formatCandidateMeta(candidate, detailReferenceTarget) || `点击切换这套${detailReferenceSourceLabel}参考图`}
</div>
<div className="flex flex-wrap gap-1.5 text-[11px] text-white/48">
{candidate.shopName ? (
<span className="rounded-full bg-white/[0.05] px-2 py-1">
{candidate.shopName}
</span>
) : null}
{candidate.categoryName ? (
<span className="rounded-full bg-white/[0.05] px-2 py-1">
{candidate.categoryName}
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="agent-dark-scrollbar max-h-[620px] space-y-3 overflow-y-auto pr-1">
{detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
return (
<div
key={card.id}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="relative min-h-[220px] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<button
type="button"
onClick={() => handleRemoveDetailReferenceCard(card.id)}
className="absolute right-3 top-3 rounded-full border border-white/10 bg-black/40 px-2 py-1 text-[11px] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
{card.imageUrl ? (
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
alt={card.label}
className="block h-auto w-full"
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
参考图 {index + 1}
</div>
{isActive ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3">
<div className="text-sm font-medium text-white/88">{card.label}</div>
<div className="text-xs text-white/45">
第 {index + 1} 张{detailReferenceSourceLabel}参考图
</div>
</div>
</div>
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
删除某张后,后面的参考图会自动上移补位,不留空位。
</div>
</div>
);
})()
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SPU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。
删除不需要的参考图后,列表会自动上移补位。
</div>
</div>
)}
</div>
</div>
<div className="min-h-0 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">替换结果</div>
<div className="text-xs text-white/40">
每张{detailReferenceSourceLabel}参考图将对应生成一张替换结果,并支持逐屏文字编辑。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已接通
</div>
</div>
<div className="agent-dark-scrollbar max-h-[720px] space-y-3 overflow-y-auto pr-1">
{detailResultCards.length > 0 ? (
detailResultCards.map((card) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
const statusMeta = getReplacementCardStatusMeta(card.status);
return (
<div
key={`${card.id}-result`}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
>
{card.resultUrl ? (
<div className="relative min-h-[220px] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
alt={`结果图 ${card.index + 1}`}
className="block h-auto w-full cursor-zoom-in"
onClick={() => {
setActiveDetailReferenceCardId(card.id);
setFullscreenPreview(card.resultUrl!);
}}
/>
<div className={cn("absolute left-3 top-3 rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
{isActive ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
</div>
) : null}
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(card.resultUrl!);
}}
className="absolute bottom-3 right-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
下载
</button>
<button
type="button"
onClick={() =>
handleSendResultToCanvasTextEdit({
imageUrl: card.resultUrl!,
title: `${selectedAgentPreset?.name || "详情替换"} 结果图 ${card.index + 1}`,
})
}
className="absolute bottom-3 left-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
送入画布改字
</button>
</div>
) : (
<div
className="flex aspect-[5/3] items-center justify-center bg-white/[0.02]"
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="space-y-2 px-4 text-center">
<div className={cn("mx-auto inline-flex rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<div className="text-sm font-medium text-white/70">
结果图 {card.index + 1}
</div>
<div className="text-xs text-white/35">
将按“输入图组 + 第 {card.index + 1} 张{detailReferenceSourceLabel}参考图”生成
</div>
</div>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{card.resultUrl
? "已生成,可继续逐屏调整文字或重新执行。"
: card.status === "generating"
? "当前正在生成这一屏,请稍候。"
: card.status === "queued"
? "前面的屏生成完成后,会继续处理这一屏。"
: card.status === "missing"
? "当前这屏还没有回传结果,可重新执行整套生成。"
: "生成完成后,这里会接入现有无限画布的文字编辑能力。"}
</div>
</div>
);
})()
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待替换结果</div>
<div className="mt-2 text-xs leading-6 text-white/40">
{detailReferenceSourceLabel}参考图加载后,右侧会按相同顺序生成对应结果图,并逐屏支持文字编辑。
</div>
</div>
)}
</div>
</div>
</div>
) : isSkuReplacementPreset ? (
<div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
<div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">输入图</div>
<div className="text-xs text-white/40">
选中的白底图会和{skuReferenceSourceLabel}参考图建立一对一替换关系
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{selectedProductAttachments.length} 张
</div>
</div>
<div className="space-y-3">
{selectedProductAttachments.length > 0 ? (
selectedProductAttachments.map((attachment, index) => (
<div
key={`${attachment?.url || "sku-input"}-${index}`}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(attachment!.url, 400)}
alt={attachment!.name || `输入图 ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
<div className="border-t border-white/10 px-3 py-2">
<div className="text-xs font-medium text-white/80">
输入图 {index + 1}
</div>
<div className="truncate text-[11px] text-white/40">
{attachment?.name || "已选择白底图"}
</div>
</div>
</div>
))
) : (
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
请先在当前“选图与生成”页的输入图槽位中确认用于 SKU 替换的白底输入图。
</div>
)}
{selectedModelAttachment ? (
<div className="rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06] p-3">
<div className="text-xs font-medium text-emerald-200">模特图</div>
<div className="mt-1 truncate text-[11px] text-emerald-100/70">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</div>
) : null}
</div>
</div>
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">{skuReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40">
支持按 SKU 或 SPU 查询任务整套候选,并切换对应的{skuReferenceSourceLabel}参考图。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{skuReplacementReferenceCards.length} 张
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{(["sku", "spu"] as const).map((queryType) => (
<button
key={queryType}
type="button"
onClick={() =>
updateSkuReplacementState((current) => ({
...current,
queryType,
candidates:
current.queryType === queryType ? current.candidates : [],
selectedCandidateId:
current.queryType === queryType ? current.selectedCandidateId : null,
references:
current.queryType === queryType ? current.references : [],
}))
}
className={cn(
"rounded-full border px-3 py-1.5 text-xs transition-colors",
skuReplacementQueryType === queryType
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/18 hover:text-white"
)}
>
按{queryType === "spu" ? "SPU" : "SKU"}查询
</button>
))}
</div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<Textarea
value={skuReplacementQueryInput}
onChange={(e) =>
updateSkuReplacementState({
query: e.target.value,
})
}
placeholder={`输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"},查询任务整套中的${skuReferenceSourceLabel}参考图`}
className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
<Button
type="button"
onClick={handleSkuReplacementSearch}
disabled={skuReplacementSearchState.isLoading}
className="h-auto min-h-[68px] rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{skuReplacementSearchState.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
查询中
</>
) : (
`查询${skuReferenceSourceLabel}`
)}
</Button>
{skuReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={handleClearSkuReferenceCards}
className="h-auto min-h-[68px] rounded-[22px] border-white/10 bg-white/[0.03] px-5 text-sm text-white hover:bg-white/[0.08]"
>
清空参考图
</Button>
) : null}
</div>
{(skuReplacementSearchState.lastQuery || skuReplacementQueryInput.trim()) ? (
<div className="mb-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
{skuReplacementQueryType === "spu" ? "SPU" : "SKU"} /
{skuReplacementSearchState.lastQuery ||
skuReplacementQueryInput.trim().toUpperCase()}
{workflowStoreName ? (
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${skuReferenceSourceLabel}参考图接口`}
</span>
</div>
) : null}
{skuReplacementCandidates.length > 0 ? (
<div className="mb-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs text-white/42">
候选整套素材 {skuReplacementCandidates.length} 套
</div>
<div className="text-[11px] text-emerald-200/70">
默认载入最新一套,可随时切换
</div>
</div>
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{skuReplacementCandidates.map((candidate) => {
const isSelected = skuReplacementSelectedCandidateId === candidate.id;
const previewUrl = getCandidatePreviewUrl(candidate, skuReferenceTarget);
return (
<button
key={candidate.id}
type="button"
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
className={cn(
"min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors",
isSelected
? "border-emerald-400/45 bg-emerald-500/[0.10]"
: "border-white/10 bg-white/[0.03] hover:border-white/18"
)}
>
<div className="relative aspect-[16/9] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.18),transparent_58%),linear-gradient(180deg,#0b0d0f_0%,#11161a_100%)]">
{previewUrl ? (
<img
src={buildPreviewImageUrl(previewUrl, 640)}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
className="h-full w-full object-cover"
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
{getCandidateTargetCount(candidate, skuReferenceTarget)} 张{skuReferenceSourceLabel}
</div>
{isSelected ? (
<div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前使用
</div>
) : null}
</div>
<div className="space-y-2 px-3 py-3">
<div className="truncate text-sm font-medium text-white">
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div>
<div className="mt-1 line-clamp-2 text-[11px] text-white/42">
{formatCandidateMeta(candidate, skuReferenceTarget) || `点击切换这套${skuReferenceSourceLabel}参考图`}
</div>
<div className="flex flex-wrap gap-1.5 text-[11px] text-white/48">
{candidate.shopName ? (
<span className="rounded-full bg-white/[0.05] px-2 py-1">
{candidate.shopName}
</span>
) : null}
{candidate.categoryName ? (
<span className="rounded-full bg-white/[0.05] px-2 py-1">
{candidate.categoryName}
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="agent-dark-scrollbar max-h-[620px] space-y-3 overflow-y-auto pr-1">
{skuReplacementReferenceCards.length > 0 ? (
skuReplacementReferenceCards.map((card, index) => (
<div
key={card.id}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
onClick={() => setActiveSkuReferenceCardId(card.id)}
>
{(() => {
const resolvedAssignedProductIndex =
selectedProductAttachments.length > 0
? Math.min(
typeof card.assignedProductIndex === "number"
? card.assignedProductIndex
: index,
Math.max(selectedProductAttachments.length - 1, 0)
)
: null;
return (
<>
<div className="relative aspect-[5/3] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<button
type="button"
onClick={() => handleRemoveSkuReferenceCard(card.id)}
className="absolute right-3 top-3 rounded-full border border-white/10 bg-black/40 px-2 py-1 text-[11px] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
{card.imageUrl ? (
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
alt={card.label}
className="h-full w-full object-cover"
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
参考图 {index + 1}
</div>
{activeSkuReferenceCardId === card.id ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3">
<div className="text-sm font-medium text-white/88">{card.label}</div>
<div className="text-xs text-white/45">
第 {index + 1} 张{skuReferenceSourceLabel}参考图
</div>
</div>
</div>
<div className="border-t border-white/10 px-3 py-2">
{selectedProductAttachments.length > 0 ? (
<>
<div className="mb-2 text-[11px] text-white/45">
当前对应输入图:
{typeof resolvedAssignedProductIndex === "number"
? `输入图 ${resolvedAssignedProductIndex + 1}`
: "未指定"}
</div>
<div className="flex flex-wrap gap-2">
{selectedProductAttachments.map((_, productIndex) => (
<button
key={`${card.id}-product-${productIndex}`}
type="button"
onClick={() =>
handleAssignSkuReferenceProduct(card.id, productIndex)
}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] transition-colors",
resolvedAssignedProductIndex === productIndex
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/20 hover:text-white"
)}
>
输入图 {productIndex + 1}
</button>
))}
</div>
</>
) : null}
<div className={cn("text-[11px] text-white/45", selectedProductAttachments.length > 0 ? "mt-2" : "")}>
删除某张后,后面的参考图会自动上移补位,不留空位。
</div>
</div>
</>
);
})()}
</div>
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待{skuReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SKU 或 SPU 后,这里会展示任务整套里的真实{skuReferenceSourceLabel}参考图。
选择多少张图,就会按相同数量进入替换流程。
</div>
</div>
)}
</div>
</div>
<div className="min-h-0 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">替换结果</div>
<div className="text-xs text-white/40">
每张{skuReferenceSourceLabel}参考图会对应一张结果图,形成一对一批量替换结果。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已接通
</div>
</div>
<div className="agent-dark-scrollbar max-h-[720px] space-y-3 overflow-y-auto pr-1">
{skuResultCards.length > 0 ? (
skuResultCards.map((card) => {
const statusMeta = getReplacementCardStatusMeta(card.status);
return (
<div
key={`${card.id}-result`}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
>
{card.resultUrl ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
alt={`结果图 ${card.index + 1}`}
className="h-full w-full cursor-zoom-in object-cover"
onClick={() => {
setActiveSkuReferenceCardId(card.id);
setFullscreenPreview(card.resultUrl!);
}}
/>
<div className={cn("absolute left-3 top-3 rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(card.resultUrl!);
}}
className="absolute bottom-3 right-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
下载
</button>
<button
type="button"
onClick={() =>
handleSendResultToCanvasTextEdit({
imageUrl: card.resultUrl!,
title: `${selectedAgentPreset?.name || "SKU替换"} 结果图 ${card.index + 1}`,
})
}
className="absolute bottom-3 left-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
送入画布改字
</button>
</div>
) : (
<div
className="flex aspect-[5/3] items-center justify-center bg-white/[0.02]"
onClick={() => setActiveSkuReferenceCardId(card.id)}
>
<div className="space-y-2 px-4 text-center">
<div className={cn("mx-auto inline-flex rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<div className="text-sm font-medium text-white/70">
结果图 {card.index + 1}
</div>
<div className="text-xs text-white/35">
将按“输入图 + 第 {card.index + 1} 张{skuReferenceSourceLabel}参考图”生成
</div>
</div>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{card.resultUrl
? "已生成,可继续逐张检查并后续批量下载。"
: card.status === "generating"
? "当前正在生成这一张,请稍候。"
: card.status === "queued"
? "前面的图生成完成后,会继续处理这一张。"
: card.status === "missing"
? "当前这张还没有回传结果,可重新执行整套生成。"
: "生成完成后,这里会接入统一文字编辑和批量下载能力。"}
</div>
</div>
);
})
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待替换结果</div>
<div className="mt-2 text-xs leading-6 text-white/40">
{skuReferenceSourceLabel}参考图加载后,右侧会按相同顺序生成一对一替换结果,并支持批量下载。
</div>
</div>
)}
</div>
</div>
</div>
) : (
<>
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 items-center justify-center"
>
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
phase="processing"
/>
</motion.div>
) : allGeneratedImages.length > 0 ? (
<motion.div
key="result"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="flex flex-1 flex-col p-5"
>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-white/45">
单击卡片高亮,双击放大预览
</div>
{selectedResultUrl ? (
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(selectedResultUrl);
}}
className="flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-sm text-white transition-colors hover:border-white/20 hover:bg-white/[0.08]"
>
<Download className="h-4 w-4" />
下载当前
</button>
) : null}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{sceneResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url;
return (
<div
key={card.id}
role={card.url ? "button" : undefined}
tabIndex={card.url ? 0 : -1}
onClick={() => {
if (!card.url) {
return;
}
setSelectedResultUrl(card.url);
}}
onDoubleClick={() => {
if (!card.url) {
return;
}
setSelectedResultUrl(card.url);
setFullscreenPreview(card.url);
}}
onKeyDown={(event) => {
if (!card.url) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedResultUrl(card.url);
}
}}
className={cn(
"group overflow-hidden rounded-[22px] border text-left transition-all",
card.url ? "cursor-pointer" : "cursor-default",
isActive
? "border-emerald-400/55 bg-emerald-500/[0.08] ring-1 ring-emerald-400/35"
: "border-white/10 bg-black/20 hover:border-white/18"
)}
>
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
{card.url ? (
<>
<img
src={buildPreviewImageUrl(card.url, 480)}
alt={`结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDownloadOriginalImage(card.url!);
}}
className="absolute right-3 top-3 rounded-full border border-white/10 bg-black/55 px-2.5 py-1 text-[11px] text-white/78 transition-colors hover:border-white/20 hover:text-white"
>
下载
</button>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-3 py-3">
<div className="text-sm font-medium text-white/88">
结果图 {card.index + 1}
</div>
<div className="text-[11px] text-white/45">
双击可放大查看
</div>
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-6 w-6 text-white/20" />
</div>
<div className="text-sm font-medium text-white/60">
结果图 {card.index + 1}
</div>
<div className="mt-2 text-xs leading-5 text-white/32">
{isGenerating
? "正在生成中,结果会按卡片顺序依次填充。"
: "开始生成后,这里会显示固定尺寸预览卡片。"}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 flex-col items-center justify-center px-6 text-center"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" />
</div>
<p className="text-base font-medium text-white/60">
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
</p>
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? hasMaterialStep
? "在“选图与生成”页完成搜图、槽位确认和参数设置后,即可开始生成"
: "在当前页补充要求并确认参数后,即可开始生成"
: "请先从顶部流程线选择一个生图模式"}
</p>
</motion.div>
)}
</AnimatePresence>
</>
)}
</motion.section>
</div>
</main>
{/* Fullscreen Preview */}
<AnimatePresence>
{fullscreenPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 backdrop-blur-sm"
onClick={() => setFullscreenPreview(null)}
>
<img
src={fullscreenPreview}
alt="预览"
className="max-h-[95vh] max-w-[95vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => setFullscreenPreview(null)}
className="absolute right-6 top-6 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<X className="h-6 w-6" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}