"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 { Input } from "@/components/ui/input"; 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 { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; 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, right: Array ) { 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; } function getAttachmentIdentityKey( attachment: | PickedAttachment | { url?: string | null; assetId?: string | null; } | null | undefined ) { const assetId = String(attachment?.assetId || "").trim(); if (assetId) { return `asset:${assetId}`; } const url = String(attachment?.url || "").trim(); return url ? `url:${url}` : null; } 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*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\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 rawUrls = params.target === "main" ? candidate.mainImageUrls || [] : params.target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; const urls = params.target === "detail" && rawUrls.length > 1 ? rawUrls.slice(1) : rawUrls; 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 rawImageCount = target === "main" ? candidate.mainImageUrls.length : target === "detail" ? candidate.detailImageUrls.length : candidate.skuImageUrls.length; const imageCount = target === "detail" && rawImageCount > 1 ? rawImageCount - 1 : rawImageCount; 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 rawTargetUrls = target === "main" ? candidate.mainImageUrls || [] : target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; const targetUrls = target === "detail" && rawTargetUrls.length > 1 ? rawTargetUrls.slice(1) : rawTargetUrls; return ( targetUrls[0] || candidate.mainImageUrls[0] || candidate.allImageUrls[0] || null ); } function getCandidatePreviewUrls( candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource, maxCount = 4 ) { const rawTargetUrls = target === "main" ? candidate.mainImageUrls || [] : target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; const targetUrls = target === "detail" && rawTargetUrls.length > 1 ? rawTargetUrls.slice(1) : rawTargetUrls; const fallbackUrls = [ ...targetUrls, ...(candidate.mainImageUrls || []), ...(candidate.allImageUrls || []), ]; return Array.from(new Set(fallbackUrls.filter(Boolean))).slice(0, maxCount); } function getCandidateTargetCount( candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource ) { const rawCount = target === "main" ? candidate.mainImageUrls.length : target === "detail" ? candidate.detailImageUrls.length : candidate.skuImageUrls.length; return target === "detail" && rawCount > 1 ? rawCount - 1 : rawCount; } type ReplacementCardStatus = | "waiting" | "queued" | "generating" | "completed" | "passthrough" | "fallback" | "missing"; function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null { const normalized = normalizeAssistantText(String(text || "")) .replace(/\s+/g, " ") .trim(); if (!normalized) { return null; } if (normalized.includes("生成失败,当前先沿用参考图")) { return "fallback"; } if ( normalized.includes("沿用参考图") || normalized.includes("未检测到可替换的家具主体") || normalized.includes("视觉判断失败") ) { return "passthrough"; } return "completed"; } function getReplacementCardStatus(params: { index: number; resultUrl: string | null; resultText?: string | null; generatedCount: number; isGenerating: boolean; }) : ReplacementCardStatus { if (params.resultUrl) { return inferReplacementCardStatusFromText(params.resultText) || "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 "passthrough": return { label: "沿用参考图", className: "border-cyan-400/30 bg-cyan-500/[0.10] text-cyan-200", }; case "fallback": return { label: "失败已沿用", className: "border-amber-400/30 bg-amber-500/[0.10] text-amber-200", }; 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>([]); const hiddenPromptRef = useRef(null); const generatedImageKeysRef = useRef>(new Set()); const lastSyncedBrandModelUrlRef = useRef(null); const lastSyncedProductSlotKeysRef = useRef>([]); const workspaceStateSignatureRef = useRef(""); const [localAgentModeState, setLocalAgentModeState] = useState( () => createEmptyAgentModeWorkspaceState() ); const normalizedIncomingWorkspaceState = useMemo( () => normalizeAgentGenerationProjectPersistedState({ workflowState: {}, workspaceState, resultImageUrls: [], }).workspaceState || createEmptyAgentGenerationProjectWorkspaceState(), [workspaceState] ); const [input, setInput] = useState(normalizedIncomingWorkspaceState.input); const [attachments, setAttachments] = useState>( 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(null); const [selectedModelId, setSelectedModelId] = useState( normalizedIncomingWorkspaceState.selectedModelId || "auto" ); const [selectedResultUrl, setSelectedResultUrl] = useState(null); const [fullscreenPreview, setFullscreenPreview] = useState(null); const [batchDownloadState, setBatchDownloadState] = useState(null); const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{ isLoading: boolean; lastQuery: string | null; }>({ isLoading: false, lastQuery: null, }); const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false); const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState(null); const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{ isLoading: boolean; lastQuery: string | null; }>({ isLoading: false, lastQuery: null, }); const [activeSkuReferenceCardId, setActiveSkuReferenceCardId] = useState(null); const [imageParams, setImageParams] = useState({ 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 brandModelAttachmentKeys = useMemo(() => { const keys = new Set(); referenceImages .filter((image) => image.source === "brandModel") .forEach((image) => { const key = getAttachmentIdentityKey(image); if (key) { keys.add(key); } }); const selectedBrandModelKey = getAttachmentIdentityKey({ url: selectedBrandModel?.imageUrl || null, assetId: selectedBrandModel?.assetId || null, }); if (selectedBrandModelKey) { keys.add(selectedBrandModelKey); } const currentModelSlotKey = modelSlotIndex >= 0 ? getAttachmentIdentityKey(attachments[modelSlotIndex]) : null; if (currentModelSlotKey) { keys.add(currentModelSlotKey); } return keys; }, [ attachments, modelSlotIndex, referenceImages, selectedBrandModel?.assetId, selectedBrandModel?.imageUrl, ]); const selectedProductAttachments = useMemo( () => attachments .slice(0, productSlotCount) .filter((item): item is PickedAttachment => { if (!item) return false; const attachmentKey = getAttachmentIdentityKey(item); return !attachmentKey || !brandModelAttachmentKeys.has(attachmentKey); }), [attachments, brandModelAttachmentKeys, productSlotCount] ); 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("preset"); const hasBrandModelStep = Boolean(brandModelPanel) && modelSlotEnabled; const hasMaterialStep = Boolean(materialSelectionPanel); const hasWorkflowStyleStep = Boolean(workflowStylePanel) || hasBrandModelStep; const { messages, setMessages, sendMessage, status, stop } = useChat({ 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 : [])); lastSyncedProductSlotKeysRef.current = []; return; } const nextSlots: Array = 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 = Array.from( { length: agentUploadSlots.length }, (_, index) => current[index] || null ); for (let index = 0; index < productSlotCount; index += 1) { const currentSlot = result[index]; const nextAttachment = nextSlots[index]; const currentSlotKey = getAttachmentIdentityKey(currentSlot); const previousSyncedKey = lastSyncedProductSlotKeysRef.current[index] || null; const currentIsPreviousAutoSelection = Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey; const currentIsBrandModel = currentSlotKey ? brandModelAttachmentKeys.has(currentSlotKey) : false; if (nextAttachment) { if (!currentSlot || currentIsPreviousAutoSelection || currentIsBrandModel) { result[index] = nextAttachment as PickedAttachment; } } else if (currentIsPreviousAutoSelection || currentIsBrandModel) { result[index] = null; } } return arePickedAttachmentsEqual(current, result) ? current : result; }); lastSyncedProductSlotKeysRef.current = Array.from( { length: productSlotCount }, (_, index) => getAttachmentIdentityKey(nextSlots[index]) ); }, [ agentUploadSlots.length, brandModelAttachmentKeys, 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 = 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(() => { 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 replacementGeneratedGroupsAfterLastUser = useMemo(() => { const lastUserIndex = [...messages] .map((message) => message.role) .lastIndexOf("user"); if (lastUserIndex < 0) { return [] as GeneratedImageGroup[]; } return 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)); }, [generatedGroups, messages]); 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 latestReplacementResults = useMemo( () => replacementGeneratedGroupsAfterLastUser.map((group) => ({ url: group.imageUrls[0] || null, text: group.text || "", })), [replacementGeneratedGroupsAfterLastUser] ); const latestReplacementResultUrls = useMemo( () => latestReplacementResults .map((item) => item.url) .filter((url): url is string => Boolean(url)), [latestReplacementResults] ); const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null; const isGenerating = status === "streaming" || status === "submitted"; const detailResultCards = useMemo( () => detailReplacementReferenceCards.map((card, index) => { const resultMeta = latestReplacementResults[index]; const resultUrl = resultMeta?.url || null; return { ...card, index, resultUrl, resultText: resultMeta?.text || "", status: getReplacementCardStatus({ index, resultUrl, resultText: resultMeta?.text || "", generatedCount: latestReplacementResults.length, isGenerating, }), }; }), [detailReplacementReferenceCards, isGenerating, latestReplacementResults] ); const skuResultCards = useMemo( () => skuReplacementReferenceCards.map((card, index) => { const resultMeta = latestReplacementResults[index]; const resultUrl = resultMeta?.url || null; return { ...card, index, resultUrl, resultText: resultMeta?.text || "", status: getReplacementCardStatus({ index, resultUrl, resultText: resultMeta?.text || "", generatedCount: latestReplacementResults.length, isGenerating, }), }; }), [isGenerating, latestReplacementResults, skuReplacementReferenceCards] ); const detailProcessedCount = useMemo( () => detailResultCards.filter((card) => ["completed", "passthrough", "fallback"].includes(card.status) ).length, [detailResultCards] ); const skuProcessedCount = useMemo( () => skuResultCards.filter((card) => ["completed", "passthrough", "fallback"].includes(card.status) ).length, [skuResultCards] ); 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 = Array.from( { length: slotCount }, (_, index) => current[index] ?? null ); slots[slotIndex] = attachment; return slots; }); }, [agentUploadSlots.length]); const handleSlotFileChange = useCallback( async (slotIndex: number, event: React.ChangeEvent) => { 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 handleStopGeneration = useCallback(() => { stop(); setMessages((current) => current); toast.info("已停止当前生成任务"); }, [setMessages, stop]); 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 | ((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 | ((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 handleApplyStitchedDetailReferenceCards = useCallback( (nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => { updateDetailReplacementState((current) => ({ ...current, references: nextCards, })); }, [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 = (
输入图槽位
{agentUploadSlots.length > 0 ? ( <>
{productAttachmentSummary} {modelAttachmentSummary ? ( {modelAttachmentSummary} ) : null} 共 {agentUploadSlots.length} 个输入槽位

{missingAttachmentCount > 0 ? `当前还需补充 ${missingAttachmentCount} 张输入素材,展开后可继续检查或替换。` : "素材已自动带入并准备完成,默认折叠显示;需要时再展开检查或替换即可。"}

) : (

先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。

)}
{agentUploadSlots.length > 0 ? ( ) : null}
{agentUploadSlots.length > 0 ? (
上一步已选的白底图和品牌模特图会自动带入对应槽位,这里只保留最终检查与单张上传替换,不再重复展示已选素材列表。
{agentUploadSlots.map((slot, index) => { return ( { fileInputRefs.current[index] = el; }} type="file" accept="image/*" className="hidden" onChange={(e) => handleSlotFileChange(index, e)} /> {attachments[index] ? ( <> {attachments[index]!.name
{slot.label}
) : (
{slotUploading === index ? ( ) : ( <> {slot.label} )}
)}
); })}
) : (
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
)}
); return (
} /> {/* Main Content */}
{showcaseConfig ? ( 0 ? "查看结果预览" : undefined} onActionClick={() => scrollToSection("agent-anchor-mode")} onSecondaryActionClick={() => scrollToSection("agent-anchor-results")} /> ) : null}
01 选择生图模式

先确定本次使用的模式,并完成品牌和店铺选择,后续的商品图、品牌模特、参考图和结果展示都会随之联动。

{isReplacementPreset ? (

{isDetailReplacementPreset ? "当前模板属于详情替换模式,结果区会按参考图顺序逐屏展示。" : "当前模板属于 SKU 替换模式,结果区会按参考图顺序一对一展示。"}

) : null}
{selectedAgentPreset ? (
{selectedAgentPreset.avatarUrl ? ( {selectedAgentPreset.name} ) : (
)}
{selectedAgentPreset.name}
{selectedAgentPreset.type === "image" ? "生图模式" : "视频模式"}
) : null} {selectedAgentPreset && onClearPreset ? ( ) : null}
{presetPanel ? (
{presetPanel}
) : (
暂无可用的生图模式配置。
)} {brandStorePanel ? (
{brandStorePanel}
) : null}
{hasMaterialStep ? (
02 选择 SPU / SKU

先搜索商品白底图与搭配图,再继续完成风格步骤和生成参数配置。

{materialSelectionPanel}
) : null} {hasWorkflowStyleStep ? (
{styleStepBadge} 风格步骤卡片

先完成风格步骤卡片,再继续选择品牌模特,方便后续做人群联动,不再跳转第二个页面。

{workflowStylePanel} {hasBrandModelStep ? brandModelPanel : null}
) : null}
{generateStepBadge} 生图与结果预览

确认补充要求、输入图槽位和生成参数后直接开始生成,结果会显示在当前页底部预览区。

补充要求