"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, Download, ImagePlus, Loader2, Sparkles, Upload, UserRound, X, Wand2, Settings2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; 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 { 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, 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; 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; } interface PickedAttachment extends Attachment {} interface GeneratedImageGroup { messageId: string; imageUrls: string[]; text: string; } interface AgentModeReferenceSearchResponse { success?: boolean; data?: { query: string; queryType: AgentWorkflowMaterialQueryType; target: "detail" | "sku"; 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 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; } 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: "detail" | "sku"; productCount?: number; }) { const candidate = params.candidate; if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"]; const urls = params.target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图"; 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.target === "sku" && (params.productCount || 0) > 0 ? Math.min(index, Math.max((params.productCount || 1) - 1, 0)) : null, })); } function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: "detail" | "sku") { const imageCount = 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(" · "); } export function AgentGenerationWorkspaceDarkV2({ chatId, projectId, selectedAgentPresetId, selectedBrandModel = null, workflowContext = null, onBack, presetPanel, materialSelectionPanel, brandModelPanel, onClearPreset, siteName, siteLogo, siteLogoDark, syncGeneratedResultsToCanvas = true, agentModeState = null, onAgentModeStateChange, onOpenCanvasEditor, workspaceState = null, onWorkspaceStateChange, onResultPreviewUrlsChange, topNavRightSlot, }: 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 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 [activeAssignmentSlotIndex, setActiveAssignmentSlotIndex] = useState(null); 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 [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{ isLoading: boolean; lastQuery: string | null; }>({ isLoading: false, lastQuery: 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 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 slotSourceLibrary = useMemo( () => referenceImages.map((image, index) => ({ id: `${image.source}-${image.id || index}`, source: image.source, sourceLabel: image.sourceLabel, label: image.label, name: image.name, url: image.url, assetId: image.assetId || undefined, mimeType: image.mimeType || undefined, })), [referenceImages] ); const activeAssignmentIsModelSlot = activeAssignmentSlotIndex !== null && modelSlotIndex >= 0 && activeAssignmentSlotIndex === modelSlotIndex; const assignableSourceLibrary = useMemo(() => { if (!slotSourceLibrary.length) return []; if (activeAssignmentIsModelSlot) { const brandModelOnly = slotSourceLibrary.filter((item) => item.source === "brandModel"); return brandModelOnly; } return slotSourceLibrary.filter((item) => item.source !== "brandModel"); }, [activeAssignmentIsModelSlot, slotSourceLibrary]); const resolvedAgentModeState = agentModeState || localAgentModeState; const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState; const detailReplacementState = resolvedAgentModeState.detailReplacement; const detailReplacementQueryInput = detailReplacementState.query; const detailReplacementQueryType = detailReplacementState.queryType; 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 { messages, setMessages, sendMessage, status } = 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 ? detailReplacementQueryType : 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 = 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 < 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 = 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 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 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 mergedSettingsStepLabel = hasMaterialStep ? "选图与生成" : "生成参数"; const mergedSettingsStepDescription = hasMaterialStep ? "搜索素材、补充要求、确认槽位并开始生成" : "补充要求并开始生成"; 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, ] ); 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 (agentUploadSlots.length === 0) { setActiveAssignmentSlotIndex(null); return; } setActiveAssignmentSlotIndex((current) => { if (current !== null && current >= 0 && current < agentUploadSlots.length) { return current; } return 0; }); }, [agentUploadSlots.length]); 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 handleAssignSourceToSlot = useCallback( (slotIndex: number, source: (typeof slotSourceLibrary)[number]) => { updateAttachmentAtSlot(slotIndex, { url: source.url, name: source.name || source.label || `槽位${slotIndex + 1}`, contentType: source.mimeType || "image/png", assetId: source.assetId, }); toast.success(`${source.label || source.name || "已选素材"} 已填入 ${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}`); }, [agentUploadSlots, slotSourceLibrary, 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("请先查询并选择详情参考图"); return; } if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { toast.error("请先查询并选择 SKU 参考图"); 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 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 (!normalized) { toast.error(`请输入${detailReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询详情图`); return; } setDetailReplacementSearchState({ isLoading: true, lastQuery: normalized, }); try { const response = await fetch( `/api/agent-workflow/reference-search?keyword=${encodeURIComponent( normalized )}&queryType=${detailReplacementQueryType}&target=detail`, { cache: "no-store" } ); const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; if (!response.ok || !result?.success || !result?.data) { throw new Error(result?.error?.message || "详情参考图查询失败"); } updateDetailReplacementState({ query: normalized, queryType: result.data.queryType || detailReplacementQueryType, 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} 张详情参考图`); } catch (error) { setDetailReplacementSearchState({ isLoading: false, lastQuery: normalized, }); toast.error(error instanceof Error ? error.message : "详情参考图查询失败"); } }, [detailReplacementQueryInput, detailReplacementQueryType, updateDetailReplacementState]); 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: "detail", productCount: selectedProductAttachments.length, }), }; }); }, [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 (!normalized) { toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询 SKU 参考图`); return; } setSkuReplacementSearchState({ isLoading: true, lastQuery: normalized, }); try { const response = await fetch( `/api/agent-workflow/reference-search?keyword=${encodeURIComponent( normalized )}&queryType=${skuReplacementQueryType}&target=sku`, { cache: "no-store" } ); const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; if (!response.ok || !result?.success || !result?.data) { throw new Error(result?.error?.message || "SKU 参考图查询失败"); } 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} 张 SKU 参考图`); } catch (error) { setSkuReplacementSearchState({ isLoading: false, lastQuery: normalized, }); toast.error(error instanceof Error ? error.message : "SKU 参考图查询失败"); } }, [ selectedProductAttachments.length, skuReplacementQueryInput, skuReplacementQueryType, updateSkuReplacementState, ]); 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: "sku", productCount: selectedProductAttachments.length, }), }; }); }, [selectedProductAttachments.length, 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 ? (
{agentUploadSlots.map((slot, index) => { const isActiveAssignmentSlot = activeAssignmentSlotIndex === 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} )}
)}
); })}
{slotSourceLibrary.length > 0 ? (
从已选素材快速替换槽位
先点击上方槽位的“设为目标”,再点击下方素材图,将它放入对应槽位。
当前目标: {activeAssignmentSlotIndex !== null ? agentUploadSlots[activeAssignmentSlotIndex]?.label || "未选择" : "未选择"}
{assignableSourceLibrary.length > 0 ? (
{assignableSourceLibrary.map((source) => { const assignedToActiveSlot = activeAssignmentSlotIndex !== null && attachments[activeAssignmentSlotIndex]?.url === source.url; return ( ); })}
) : (
{activeAssignmentIsModelSlot ? "当前模特槽位还没有可分配的品牌模特图,请先在“品牌模特”步骤里选择。" : "当前产品槽位还没有来自已选素材区的图片,请先在当前页上方完成 SKU / SPU 选图。"}
)}
) : null}
) : (
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
)}
); return (
} /> {/* Main Content */}
Current Module

{activeToolStepMeta.label}

{activeToolStepMeta.description}

{isReplacementPreset ? (

{isDetailReplacementPreset ? "当前模板属于详情替换模式,下一步将切入专用工作台进行逐屏替换。" : "当前模板属于 SKU 替换模式,下一步将切入专用工作台进行一对一替换。"}

) : null}
{selectedAgentPreset ? (
{selectedAgentPreset.avatarUrl ? ( {selectedAgentPreset.name} ) : (
)}
{selectedAgentPreset.name}
{selectedAgentPreset.type === "image" ? "生图模式" : "视频模式"}
) : null} {selectedAgentPreset && onClearPreset ? ( ) : null}
{presetPanel ? (
{presetPanel}
) : (
暂无可用的生图模式配置。
)}
{hasBrandModelStep ? (
{brandModelPanel}
) : null}
{hasMaterialStep ? materialSelectionPanel : null}

补充要求