"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type SyntheticEvent, } from "react"; import useSWR from "swr"; import { motion, AnimatePresence } from "framer-motion"; import JSZip from "jszip"; import { ArrowUp, ChevronDown, ChevronUp, Download, ImagePlus, Loader2, Maximize2, Sparkles, Upload, UserRound, Trash2, X, Wand2, Settings2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ClientErrorBoundary } from "@/components/ui/client-error-boundary"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; 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 { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { ImageAnnotator } from "@/components/image-annotator"; 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 { AGENT_DETAIL_BUNDLE_SLOT_INDEXES, AGENT_DETAIL_FIELD_EXAMPLES, AGENT_DETAIL_MODEL_SLOT_INDEX, AGENT_DETAIL_PRODUCT_SLOT_INDEXES, AGENT_DETAIL_SLOT_CONFIGS, AGENT_DETAIL_TARGET_HEIGHT, AGENT_DETAIL_TARGET_WIDTH, createEmptyAgentDetailBriefForm, createEmptyAgentDetailSlots, extractAgentDetailFrameCount, normalizeAgentDetailGenerationMode, sanitizeAgentDetailBrief, sanitizeDetailImageModelPrompt, } from "@/lib/agents/detail-generation-config"; import { normalizeAgentPresetModeConfig, } from "@/lib/agents/preset-mode-config"; import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt"; import type { AgentPsdTemplateMode } from "@/lib/agents/psd-template-config"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import type { AgentDetailBriefForm, AgentDetailGenerationProgressState, AgentGenerationProjectWorkspaceState, AgentGenerationProjectResultCollections, AgentGenerationResultModeType, 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; workflowPromptOptimizationEnabled?: boolean; getLatestWorkflowContext?: () => 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; resultCollections?: AgentGenerationProjectResultCollections | null; deletedResultCollections?: AgentGenerationProjectResultCollections | null; onResultPreviewUrlsChange?: ( modeType: AgentGenerationResultModeType, urls: string[] ) => void; onDeleteResultImage?: ( modeType: AgentGenerationResultModeType, imageUrl: string ) => void; topNavRightSlot?: ReactNode; showcaseConfig?: AgentWorkflowShowcaseConfig | null; workflowStylePanel?: ReactNode; } interface PickedAttachment extends Attachment {} const MAX_CHAT_TEXT_PART_LENGTH = 2000; const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_REFERENCE_COUNT = 40; const CHAT_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const ALLOWED_CHAT_MEDIA_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", "image/gif", "image/jpg", "image/bmp", "image/svg+xml", ]); function isValidChatUuid(value?: string | null): value is string { return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim())); } function isValidChatHttpUrl(value?: string | null): value is string { if (!value) return false; try { const parsed = new URL(String(value).trim()); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } function clampChatRequestText(value?: string | null, maxLength = MAX_CHAT_TEXT_PART_LENGTH): string { return String(value || "").slice(0, maxLength); } function normalizeChatAttachmentName(value?: string | null): string { const normalized = String(value || "").trim() || "参考图"; return normalized.slice(0, MAX_CHAT_ATTACHMENT_NAME_LENGTH); } function normalizeChatMediaType(value?: string | null): string { const normalized = String(value || "").trim().toLowerCase(); if (normalized === "image/pjpeg") return "image/jpeg"; if (normalized === "image/x-png") return "image/png"; if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) { return normalized; } return "image/jpeg"; } function getPsdTemplateModeByResultMode(modeType: AgentGenerationResultModeType): AgentPsdTemplateMode { return modeType === "sku_replacement" ? "sku" : "main"; } function sanitizeChatAttachment( attachment?: PickedAttachment | null, ): PickedAttachment | null { if (!attachment || !isValidChatHttpUrl(attachment.url)) { return null; } return { ...attachment, url: attachment.url, name: normalizeChatAttachmentName(attachment.name), contentType: normalizeChatMediaType(attachment.contentType), assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, }; } 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 normalizeResultCollectionUrls(urls?: string[] | null) { return Array.from( new Set( (urls || []) .map((item) => String(item || "").trim()) .filter((item) => /^https?:\/\//i.test(item)) ) ); } function excludeDeletedResultUrls( urls: string[] | undefined | null, deletedUrls: string[] | undefined | null ) { const deletedUrlSet = new Set(normalizeResultCollectionUrls(deletedUrls)); return normalizeResultCollectionUrls(urls).filter((url) => !deletedUrlSet.has(url)); } function getResultModeLabel(modeType: AgentGenerationResultModeType) { return modeType === "detail_generation" ? "AI详情" : modeType === "detail_replacement" ? "参考图替换" : modeType === "sku_replacement" ? "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 areAttachmentValuesEqual( left: | PickedAttachment | { url?: string | null; name?: string | null; contentType?: string | null; assetId?: string | null; } | null | undefined, right: | PickedAttachment | { url?: string | null; name?: string | null; contentType?: string | null; assetId?: string | null; } | null | undefined ) { return ( String(left?.url || "").trim() === String(right?.url || "").trim() && String(left?.name || "").trim() === String(right?.name || "").trim() && String(left?.contentType || "").trim() === String(right?.contentType || "").trim() && String(left?.assetId || "").trim() === String(right?.assetId || "").trim() ); } function mergePersistedHistoryMessages( currentMessages: ChatMessage[], persistedMessages: ChatMessage[] ) { if (persistedMessages.length === 0) { return currentMessages; } if (currentMessages.length === 0) { return persistedMessages; } const persistedIds = new Set(persistedMessages.map((message) => message.id)); const extraLocalMessages = currentMessages.filter( (message) => !persistedIds.has(message.id) ); const mergedMessages = [...persistedMessages, ...extraLocalMessages]; if ( mergedMessages.length === currentMessages.length && mergedMessages.every((message, index) => currentMessages[index]?.id === message.id) ) { return currentMessages; } return mergedMessages; } 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; } function toWorkflowReferenceAttachment(params: { url?: string | null; name?: string | null; label?: string | null; mimeType?: string | null; assetId?: string | null; }): PickedAttachment | null { const url = String(params.url || "").trim(); if (!isValidChatHttpUrl(url)) { return null; } return { url, name: normalizeChatAttachmentName(params.name || params.label || "参考图"), contentType: normalizeChatMediaType(params.mimeType || "image/png"), assetId: isValidChatUuid(params.assetId) ? params.assetId || undefined : undefined, }; } type ToolStepKey = | "preset" | "brand-model" | "materials" | "prompt" | "settings"; function buildPreviewImageUrl(url: string, width: number) { return buildImageProxyUrl(url, { width, quality: 86, format: "webp" }); } function handlePreviewImageLoadError( event: SyntheticEvent, originalUrl: string ) { const fallbackUrl = String(originalUrl || "").trim(); if (!fallbackUrl) { return; } const image = event.currentTarget; if (image.dataset.previewFallbackApplied === "1") { return; } image.dataset.previewFallbackApplied = "1"; image.src = fallbackUrl; } 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 hasLikelyGenerationErrorText(value: string | null | undefined) { const normalized = normalizeAssistantText(String(value || "")); if (!normalized) { return false; } return /失败|超时|重试|error|failed/i.test(normalized); } 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; }); } type DirectDetailGenerationImage = { originalUrl: string; thumbnailUrl: string; title?: string; subtitle?: string; copy?: string; }; type DirectDetailReviewResult = { reviewAction?: "pass" | "protocol_patch" | "regenerate_image"; revisedPrompt?: string; issues?: string[]; uiValidation?: { passed?: boolean; issues?: string[]; }; }; const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600; const AGENT_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000; function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } async function requestCanvasImageGeneration( payload: { prompt: string; model?: string; aspectRatio?: string; imageSize?: string; imageCount?: number; negativePrompt?: string; referenceImage?: string; referenceImages?: string[]; } ): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> { const createResponse = await fetch("/api/canvas/generate/image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, async: true, }), }); const createResult = await createResponse.json().catch(() => ({})); if (!createResponse.ok || !createResult?.success) { throw new Error(createResult?.error || "创建生图任务失败"); } if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) { return { images: createResult.data.images }; } const taskId = String(createResult?.taskId || "").trim(); if (!taskId) { throw new Error("创建生图任务失败:未返回任务ID"); } const deadline = Date.now() + AGENT_IMAGE_TASK_TIMEOUT_MS; let lastError = ""; while (Date.now() < deadline) { await sleep(AGENT_IMAGE_TASK_POLL_INTERVAL_MS); const statusResponse = await fetch( `/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`, { cache: "no-store" } ); const statusResult = await statusResponse.json().catch(() => ({})); if (!statusResponse.ok || !statusResult?.success) { lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`; continue; } const task = statusResult?.task; const status = String(task?.status || "").trim(); if (status === "succeeded") { const result = task?.result || {}; const images = Array.isArray(result?.images) ? result.images : []; if (images.length === 0) { throw new Error("任务完成但未返回图片"); } return { images }; } if (status === "failed") { throw new Error(String(task?.error || "图片生成失败")); } } throw new Error(lastError || "图片生成超时,请稍后重试"); } 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 = 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 = 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 = 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 = 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 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", }; } } interface LocalGenerationPendingState { chatId: string; modeType: AgentGenerationResultModeType; expectedCount: number; userTurnCountAtStart: number; timestamp: number; } const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations"; const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000; const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000; function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { if (typeof window === "undefined") { return []; } try { const raw = window.localStorage.getItem(AGENT_PENDING_GENERATION_STORAGE_KEY); if (!raw) { return []; } const now = Date.now(); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { return []; } return parsed.filter((item) => { if (!item || typeof item !== "object") { return false; } const record = item as Record; const chatId = String(record.chatId || "").trim(); const modeType = String(record.modeType || "").trim(); const expectedCount = Number(record.expectedCount); const userTurnCountAtStart = Number(record.userTurnCountAtStart); const timestamp = Number(record.timestamp); return ( Boolean(chatId) && (modeType === "scene_generation" || modeType === "detail_generation" || modeType === "detail_replacement" || modeType === "sku_replacement") && Number.isFinite(expectedCount) && expectedCount > 0 && Number.isFinite(userTurnCountAtStart) && Number.isFinite(timestamp) && now - timestamp < AGENT_PENDING_GENERATION_EXPIRY_MS ); }) as LocalGenerationPendingState[]; } catch { return []; } } function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[]) { if (typeof window === "undefined") { return; } try { window.localStorage.setItem( AGENT_PENDING_GENERATION_STORAGE_KEY, JSON.stringify(items) ); } catch (error) { console.warn( "[AgentGenerationWorkspaceDarkV2] Failed to persist pending generation state:", error ); } } function AnimatedGenerationPlaceholderCard(params: { title: string; description: string; badge: string; badgeClassName?: string; showSpinner?: boolean; }) { return (
{params.badge} {params.showSpinner ? ( ) : null}
{params.title}
{params.description}
); } export function AgentGenerationWorkspaceDarkV2({ chatId, projectId, selectedAgentPresetId, selectedBrandModel = null, workflowContext = null, workflowPromptOptimizationEnabled = true, getLatestWorkflowContext, onBack, presetPanel, brandStorePanel, materialSelectionPanel, brandModelPanel, onClearPreset, siteName, siteLogo, siteLogoDark, syncGeneratedResultsToCanvas = true, agentModeState = null, onAgentModeStateChange, onOpenCanvasEditor, workspaceState = null, resultCollections = null, deletedResultCollections = null, onWorkspaceStateChange, onResultPreviewUrlsChange, onDeleteResultImage, 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 detailSlotInputRefs = useRef>([]); const hiddenPromptRef = useRef(null); const generatedImageKeysRef = useRef>(new Set()); const lastSyncedBrandModelUrlRef = useRef(null); const lastSyncedProductSlotKeysRef = useRef>([]); const lastSyncedDetailProductSlotKeysRef = useRef>([]); const lastSyncedDetailBundleSlotKeysRef = useRef>([]); const lastSyncedDetailModelSlotKeyRef = useRef(null); const detailGenerationCancelRef = useRef(false); const workspaceStateSignatureRef = useRef(""); const generationRequestSnapshotRef = useRef<{ hiddenPrompt: string | null; }>({ hiddenPrompt: null, }); 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 [isScenePromptExpanded, setIsScenePromptExpanded] = useState(false); const [slotUploading, setSlotUploading] = useState(null); const [selectedModelId, setSelectedModelId] = useState( normalizedIncomingWorkspaceState.selectedModelId || "auto" ); const [selectedResultUrl, setSelectedResultUrl] = useState(null); const [activeResultModeType, setActiveResultModeType] = useState("scene_generation"); const [fullscreenPreview, setFullscreenPreview] = useState(null); const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState(null); const [isPsdTemplateDialogOpen, setIsPsdTemplateDialogOpen] = useState(false); const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState(null); const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState("main"); const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] = useState("scene_generation"); 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 [pendingGeneration, setPendingGeneration] = useState(null); const [detailGenerationRunResults, setDetailGenerationRunResults] = useState< DirectDetailGenerationImage[] >([]); const closePsdTemplateDialog = useCallback(() => { setIsPsdTemplateDialogOpen(false); setPsdTemplateDialogSourceUrl(null); setPsdTemplateDialogMode("main"); setPsdTemplateDialogResultModeType("scene_generation"); }, []); const openPsdTemplateDialog = useCallback( (params?: { sourceImageUrl?: string | null; mode?: AgentPsdTemplateMode | null; resultModeType?: AgentGenerationResultModeType | null; }) => { setPsdTemplateDialogSourceUrl( isValidChatHttpUrl(params?.sourceImageUrl) ? String(params?.sourceImageUrl).trim() : null ); setPsdTemplateDialogMode( params?.mode === "sku" ? "sku" : params?.mode === "main" ? "main" : "main" ); setPsdTemplateDialogResultModeType( params?.resultModeType || activeResultModeType || "scene_generation" ); setIsPsdTemplateDialogOpen(true); }, [activeResultModeType] ); const normalizedDeletedResultCollections = useMemo( () => ({ scene_generation: normalizeResultCollectionUrls( deletedResultCollections?.scene_generation ), detail_generation: normalizeResultCollectionUrls( deletedResultCollections?.detail_generation ), detail_replacement: normalizeResultCollectionUrls( deletedResultCollections?.detail_replacement ), sku_replacement: normalizeResultCollectionUrls( deletedResultCollections?.sku_replacement ), }), [deletedResultCollections] ); useEffect(() => { const restored = getStoredAgentPendingGenerations().find( (item) => item.chatId === chatId ); if (!restored) { return; } setPendingGeneration((current) => current || restored); }, [chatId]); useEffect(() => { const allItems = getStoredAgentPendingGenerations().filter( (item) => item.chatId !== chatId ); if (pendingGeneration) { allItems.push(pendingGeneration); } saveStoredAgentPendingGenerations(allItems); }, [chatId, pendingGeneration]); const { data: historyData } = useSWR<{ messages: ChatMessage[] }>( rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher, { refreshInterval: selectedAgentPresetId ? 3000 : 0, revalidateOnFocus: true, } ); 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 resolvedAgentModeState = agentModeState || localAgentModeState; const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState; const detailReplacementState = resolvedAgentModeState.detailReplacement; const detailReplacementMethod = detailReplacementState.method === "ai_detail_generate" ? "ai_detail_generate" : "reference_replace"; 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 isDetailAiGenerationMethod = isDetailReplacementPreset && detailReplacementMethod === "ai_detail_generate"; const isReplacementPreset = (isDetailReplacementPreset && !isDetailAiGenerationMethod) || isSkuReplacementPreset; const currentResultModeType: AgentGenerationResultModeType = isDetailReplacementPreset && isDetailAiGenerationMethod ? "detail_generation" : isDetailReplacementPreset ? "detail_replacement" : isSkuReplacementPreset ? "sku_replacement" : "scene_generation"; 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 requiredProductUploadCount = hasAgentUploadSlots ? 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 scenePrimaryReferenceCount = useMemo( () => referenceImages.filter((image) => image.source === "primary").length, [referenceImages] ); const sceneSecondaryReferenceAttachments = useMemo(() => { const seenKeys = new Set(); return referenceImages .filter((image) => image.source === "secondary") .map((image) => toWorkflowReferenceAttachment({ url: image.url, name: image.name, label: image.label, mimeType: image.mimeType, assetId: image.assetId || null, }) ) .filter((attachment): attachment is PickedAttachment => { if (!attachment) return false; const key = getAttachmentIdentityKey(attachment); if (key && seenKeys.has(key)) { return false; } if (key) { seenKeys.add(key); } return true; }); }, [referenceImages]); const sceneSecondaryReferenceCount = sceneSecondaryReferenceAttachments.length; const sceneMessageAttachments = useMemo(() => { if (!isSceneGenerationPreset) { return attachments .map((attachment) => sanitizeChatAttachment(attachment)) .filter((attachment): attachment is PickedAttachment => Boolean(attachment)); } const ordered: PickedAttachment[] = []; const seenKeys = new Set(); const pushUniqueAttachment = ( attachment: | PickedAttachment | { url?: string | null; name?: string | null; contentType?: string | null; assetId?: string | null; } | null | undefined ) => { const sanitized = sanitizeChatAttachment(attachment as PickedAttachment | null | undefined); if (!sanitized) return; const key = getAttachmentIdentityKey(sanitized); if (key && seenKeys.has(key)) { return; } if (key) { seenKeys.add(key); } ordered.push(sanitized); }; const primaryVisibleCount = Math.min( Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0), selectedProductAttachments.length ); selectedProductAttachments.slice(0, primaryVisibleCount).forEach((attachment) => { pushUniqueAttachment(attachment); }); selectedProductAttachments.slice(primaryVisibleCount).forEach((attachment) => { pushUniqueAttachment(attachment); }); sceneSecondaryReferenceAttachments.forEach((attachment) => { pushUniqueAttachment(attachment); }); pushUniqueAttachment(selectedModelAttachment); attachments.forEach((attachment) => { pushUniqueAttachment(attachment); }); return ordered; }, [ attachments, isSceneGenerationPreset, scenePrimaryReferenceCount, sceneSecondaryReferenceAttachments, selectedModelAttachment, selectedProductAttachments, ]); const filledProductAttachmentCount = selectedProductAttachments.length; const missingAttachmentCount = Math.max( requiredProductUploadCount - filledProductAttachmentCount, 0 ); const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`; const modelAttachmentSummary = modelSlotEnabled ? selectedModelAttachment ? "模特图已带入" : "模特图可不选" : null; const attachmentSummaryReady = hasAgentUploadSlots && (requiredProductUploadCount === 0 || filledProductAttachmentCount >= requiredProductUploadCount); const sceneImageRolePromptPreview = useMemo(() => { const sceneHasModelReference = Boolean(selectedModelAttachment); if ( !isSceneGenerationPreset || scenePrimaryReferenceCount + sceneSecondaryReferenceCount + (sceneHasModelReference ? 1 : 0) <= 0 ) { return ""; } return buildAgentSceneImageRolePrompt({ primaryProductImageCount: Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0), bundleProductImageCount: sceneSecondaryReferenceCount, includeModelImage: sceneHasModelReference, }); }, [ isSceneGenerationPreset, scenePrimaryReferenceCount, sceneSecondaryReferenceCount, selectedModelAttachment, selectedProductAttachments.length, ]); const detailReplacementQueryInput = detailReplacementState.query; const detailReplacementCandidates = detailReplacementState.candidates; const detailReplacementSelectedCandidateId = detailReplacementState.selectedCandidateId; const detailReplacementReferenceCards = detailReplacementState.references; const detailGenerationMode = normalizeAgentDetailGenerationMode( detailReplacementState.generationMode ); const detailGenerationBrief = sanitizeAgentDetailBrief(detailReplacementState.brief); const detailGenerationSlots = Array.isArray(detailReplacementState.slots) && detailReplacementState.slots.length === AGENT_DETAIL_SLOT_CONFIGS.length ? detailReplacementState.slots : createEmptyAgentDetailSlots(); const detailGenerationRequiredProductCount = Math.max( 1, Math.min( isDetailReplacementPreset ? presetModeConfig.detail.productImageSlotCount : productSlotCount, AGENT_DETAIL_PRODUCT_SLOT_INDEXES.length ) ); const detailGenerationProductSlotIndexes = useMemo( () => AGENT_DETAIL_PRODUCT_SLOT_INDEXES.slice(0, detailGenerationRequiredProductCount), [detailGenerationRequiredProductCount] ); const detailGenerationVisibleSlotIndexes = useMemo( () => [ ...detailGenerationProductSlotIndexes, ...(modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 ? [AGENT_DETAIL_MODEL_SLOT_INDEX] : []), ...AGENT_DETAIL_BUNDLE_SLOT_INDEXES, ], [detailGenerationProductSlotIndexes, modelSlotEnabled] ); const detailGenerationProgress = (detailReplacementState.progress || { status: "idle", stage: "", current: 0, total: 0, startedAt: null, finishedAt: null, }) as AgentDetailGenerationProgressState; const detailGenerationProductCount = detailGenerationProductSlotIndexes.filter( (index) => Boolean(detailGenerationSlots[index]?.url) ).length; const detailGenerationBundleCount = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.filter( (index) => Boolean(detailGenerationSlots[index]?.url) ).length; const detailGenerationModelReady = Boolean( modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 && detailGenerationSlots[AGENT_DETAIL_MODEL_SLOT_INDEX]?.url ); const detailGenerationValidationIssues = useMemo(() => { const issues: string[] = []; if (!detailGenerationBrief.targetAudience) issues.push("缺少主购买人群"); if (!detailGenerationBrief.usageScenario) issues.push("缺少需求场景"); if (!detailGenerationBrief.coreNeed) issues.push("缺少核心需求"); if (detailGenerationProductCount < detailGenerationRequiredProductCount) { issues.push( `产品图不足(${detailGenerationProductCount}/${detailGenerationRequiredProductCount})` ); } return issues; }, [ detailGenerationBrief.coreNeed, detailGenerationBrief.targetAudience, detailGenerationBrief.usageScenario, detailGenerationProductCount, detailGenerationRequiredProductCount, ]); 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 transportStateRef = useRef<{ selectedAgentPresetId: string | null; selectedAgentPresetType: AgentPreset["type"] | null; selectedModelId: string; imageParams: ImageGenerationParams; isDetailReplacementPreset: boolean; isSkuReplacementPreset: boolean; detailReplacementQueryInput: string; detailReplacementSelectedCandidateId: string | null; detailReplacementReferenceCards: typeof detailReplacementReferenceCards; skuReplacementQueryInput: string; skuReplacementQueryType: typeof skuReplacementQueryType; skuReplacementSelectedCandidateId: string | null; skuReplacementReferenceCards: typeof skuReplacementReferenceCards; selectedProductAttachmentCount: number; }>({ selectedAgentPresetId: null, selectedAgentPresetType: null, selectedModelId: "auto", imageParams: { aspectRatio: "auto", imageSize: "2K", imageCount: 1, }, isDetailReplacementPreset: false, isSkuReplacementPreset: false, detailReplacementQueryInput: "", detailReplacementSelectedCandidateId: null, detailReplacementReferenceCards: [], skuReplacementQueryInput: "", skuReplacementQueryType: "sku", skuReplacementSelectedCandidateId: null, skuReplacementReferenceCards: [], selectedProductAttachmentCount: 0, }); transportStateRef.current = { selectedAgentPresetId: selectedAgentPresetId || null, selectedAgentPresetType: selectedAgentPreset?.type || null, selectedModelId, imageParams, isDetailReplacementPreset, isSkuReplacementPreset, detailReplacementQueryInput, detailReplacementSelectedCandidateId: detailReplacementSelectedCandidateId || null, detailReplacementReferenceCards, skuReplacementQueryInput, skuReplacementQueryType, skuReplacementSelectedCandidateId: skuReplacementSelectedCandidateId || null, skuReplacementReferenceCards, selectedProductAttachmentCount: selectedProductAttachments.length, }; 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 transportState = transportStateRef.current; const resolvedAgentPresetId = transportState.selectedAgentPresetId || selectedAgentPresetId || undefined; const resolvedSelectedModelId = transportState.selectedModelId !== "auto" ? transportState.selectedModelId : selectedAgentPreset?.defaultModelId || transportState.selectedModelId; const replacementModePayload = transportState.selectedAgentPresetType === "image" && (transportState.isDetailReplacementPreset || transportState.isSkuReplacementPreset) ? { modeType: transportState.isDetailReplacementPreset ? ("detail_replacement" as const) : ("sku_replacement" as const), query: clampChatRequestText( transportState.isDetailReplacementPreset ? transportState.detailReplacementQueryInput.trim() : transportState.skuReplacementQueryInput.trim(), MAX_AGENT_MODE_QUERY_LENGTH ), queryType: transportState.isDetailReplacementPreset ? ("spu" as const) : transportState.skuReplacementQueryType, selectedCandidateId: transportState.isDetailReplacementPreset ? transportState.detailReplacementSelectedCandidateId || null : transportState.skuReplacementSelectedCandidateId || null, references: ( transportState.isDetailReplacementPreset ? transportState.detailReplacementReferenceCards : transportState.skuReplacementReferenceCards ) .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) .filter((item) => Boolean(String(item.imageUrl || "").trim())) .map((item) => ({ id: String(item.id || "").slice(0, 128), label: String(item.label || "").slice(0, 255), imageUrl: String(item.imageUrl || "").trim(), candidateId: item.candidateId ? String(item.candidateId).slice(0, 128) : null, sortOrder: item.sortOrder ?? 0, assignedProductIndex: transportState.selectedProductAttachmentCount > 0 ? Math.min( item.assignedProductIndex ?? 0, Math.max(transportState.selectedProductAttachmentCount - 1, 0) ) : null, })), } : undefined; return { body: { id: rawChatId, message: request.messages.at(-1), hiddenPrompt: clampChatRequestText( generationRequestSnapshotRef.current.hiddenPrompt || hiddenPromptRef.current || "", MAX_CHAT_HIDDEN_PROMPT_LENGTH ) || undefined, workflowPromptOptimizationEnabled, agentPresetId: isValidChatUuid(resolvedAgentPresetId) ? resolvedAgentPresetId : undefined, selectedChatModel: String(resolvedSelectedModelId || "auto").trim() || "auto", composerMode: selectedAgentPreset?.type === "image" ? ("image" as const) : ("agent" as const), selectedVisibilityType: "private", imageParams: transportState.imageParams, contextPayload: { source: "chat" as const, projectId: isValidChatUuid(projectId) ? projectId : undefined, }, agentModePayload: replacementModePayload, }, }; }, }), }); useEffect(() => { if (!historyMessages.length) { return; } setMessages((currentMessages) => mergePersistedHistoryMessages(currentMessages, historyMessages) ); }, [historyMessages, 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(() => { workspaceStateSignatureRef.current = ""; lastSyncedProductSlotKeysRef.current = []; lastSyncedBrandModelUrlRef.current = null; lastSyncedDetailProductSlotKeysRef.current = []; lastSyncedDetailBundleSlotKeysRef.current = []; lastSyncedDetailModelSlotKeyRef.current = null; setInput(""); setAttachments([]); setSelectedResultUrl(null); setFullscreenPreview(null); setPendingGeneration(null); setImageParams({ aspectRatio: "auto", imageSize: "2K", imageCount: 1, }); }, [projectId]); 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 nextAttachment = nextSlots[index]; const previousSyncedKey = lastSyncedProductSlotKeysRef.current[index] || null; if (nextAttachment) { result[index] = nextAttachment as PickedAttachment; } else if (previousSyncedKey) { 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) { 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 userMessageCount = useMemo( () => messages.filter((message) => message.role === "user").length, [messages] ); const assistantMessagesAfterLastUser = useMemo(() => { const lastUserIndex = [...messages] .map((message) => message.role) .lastIndexOf("user"); if (lastUserIndex < 0) { return [] as ChatMessage[]; } return messages .slice(lastUserIndex + 1) .filter((message) => message.role === "assistant"); }, [messages]); const hasAssistantFailureAfterLastUser = useMemo( () => assistantMessagesAfterLastUser.some((message) => { const text = (message.parts || []) .filter((part: any) => part.type === "text" && part.text) .map((part: any) => String(part.text)) .join("\n\n"); if (!hasLikelyGenerationErrorText(text)) { return false; } const imageUrls = extractImageUrlsFromText(text); return imageUrls.length === 0; }), [assistantMessagesAfterLastUser] ); const generatedGroupsAfterLastUser = useMemo(() => { return assistantMessagesAfterLastUser .map((message) => generatedGroups.find((group) => group.messageId === message.id) || null) .filter((group): group is GeneratedImageGroup => Boolean(group)); }, [assistantMessagesAfterLastUser, generatedGroups]); const allGeneratedImages = useMemo( () => Array.from( new Set( [ ...generatedGroups.flatMap((group) => group.imageUrls), ...detailGenerationRunResults.map((item) => item.originalUrl), ...normalizeResultCollectionUrls(resultCollections?.scene_generation), ...normalizeResultCollectionUrls(resultCollections?.detail_generation), ...normalizeResultCollectionUrls(resultCollections?.detail_replacement), ...normalizeResultCollectionUrls(resultCollections?.sku_replacement), ].filter( (url) => !normalizedDeletedResultCollections.scene_generation.includes(url) && !normalizedDeletedResultCollections.detail_generation.includes(url) && !normalizedDeletedResultCollections.detail_replacement.includes(url) && !normalizedDeletedResultCollections.sku_replacement.includes(url) ) ) ), [detailGenerationRunResults, generatedGroups, normalizedDeletedResultCollections, resultCollections] ); const currentSceneGeneratedUrls = useMemo( () => Array.from(new Set(generatedGroupsAfterLastUser.flatMap((group) => group.imageUrls))), [generatedGroupsAfterLastUser] ); const replacementGeneratedGroupsAfterLastUser = useMemo(() => { return assistantMessagesAfterLastUser .map((message) => generatedGroups.find((group) => group.messageId === message.id) || null) .filter((group): group is GeneratedImageGroup => Boolean(group)); }, [assistantMessagesAfterLastUser, 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 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)) .filter( (url) => !normalizedDeletedResultCollections[currentResultModeType].includes(url) ), [currentResultModeType, latestReplacementResults, normalizedDeletedResultCollections] ); const detailGenerationRunResultUrls = useMemo( () => detailGenerationRunResults .map((item) => item.originalUrl) .filter(Boolean) .filter( (url) => !normalizedDeletedResultCollections.detail_generation.includes(url) ), [detailGenerationRunResults, normalizedDeletedResultCollections.detail_generation] ); const currentLiveResultUrls = currentResultModeType === "detail_generation" ? detailGenerationRunResultUrls : isReplacementPreset ? latestReplacementResultUrls : currentSceneGeneratedUrls; const transportIsGenerating = status === "streaming" || status === "submitted"; const pendingGenerationProgressCount = useMemo(() => { if (!pendingGeneration) { return 0; } if ( pendingGeneration.modeType !== "detail_generation" && userMessageCount <= pendingGeneration.userTurnCountAtStart ) { return 0; } if (pendingGeneration.modeType === "scene_generation") { return currentSceneGeneratedUrls.length; } if (pendingGeneration.modeType === "detail_generation") { return detailGenerationRunResults.length; } return latestReplacementResults.length; }, [ currentSceneGeneratedUrls.length, detailGenerationRunResults.length, latestReplacementResults.length, pendingGeneration, userMessageCount, ]); const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration); const isSceneResultModeGenerating = (activeResultModeType === "scene_generation" && pendingGeneration?.modeType === "scene_generation") || (activeResultModeType === currentResultModeType && currentResultModeType === "scene_generation" && transportIsGenerating); const isDetailResultModeGenerating = pendingGeneration?.modeType === "detail_replacement" || (currentResultModeType === "detail_replacement" && transportIsGenerating); const isDetailGenerationResultModeGenerating = pendingGeneration?.modeType === "detail_generation"; const isSkuResultModeGenerating = pendingGeneration?.modeType === "sku_replacement" || (currentResultModeType === "sku_replacement" && transportIsGenerating); const mergedResultCollections = useMemo( () => { const baseCollections = { scene_generation: excludeDeletedResultUrls( resultCollections?.scene_generation, normalizedDeletedResultCollections.scene_generation ), detail_generation: excludeDeletedResultUrls( resultCollections?.detail_generation, normalizedDeletedResultCollections.detail_generation ), detail_replacement: excludeDeletedResultUrls( resultCollections?.detail_replacement, normalizedDeletedResultCollections.detail_replacement ), sku_replacement: excludeDeletedResultUrls( resultCollections?.sku_replacement, normalizedDeletedResultCollections.sku_replacement ), }; return { ...baseCollections, [currentResultModeType]: Array.from( new Set([ ...baseCollections[currentResultModeType], ...excludeDeletedResultUrls( currentLiveResultUrls, normalizedDeletedResultCollections[currentResultModeType] ), ]) ), } as Record; }, [ currentLiveResultUrls, currentResultModeType, normalizedDeletedResultCollections, resultCollections, ] ); const availableResultModeTypes = useMemo( () => ([ "scene_generation", "detail_generation", "detail_replacement", "sku_replacement", ] as AgentGenerationResultModeType[]) .filter( (modeType) => modeType === currentResultModeType || modeType === pendingGeneration?.modeType || (mergedResultCollections[modeType] || []).length > 0 ), [currentResultModeType, mergedResultCollections, pendingGeneration?.modeType] ); const activeResultModeUrls = mergedResultCollections[activeResultModeType] || []; const displayedResultUrl = selectedResultUrl || activeResultModeUrls[0] || null; const detailResultCards = useMemo( () => detailReplacementReferenceCards.map((card, index) => { const resultMeta = latestReplacementResults[index]; const resultUrl = resultMeta?.url && !normalizedDeletedResultCollections.detail_replacement.includes(resultMeta.url) ? resultMeta.url : null; return { ...card, index, resultUrl, resultText: resultUrl ? resultMeta?.text || "" : "", status: getReplacementCardStatus({ index, resultUrl, resultText: resultUrl ? resultMeta?.text || "" : "", generatedCount: latestReplacementResults.length, isGenerating: isDetailResultModeGenerating, }), }; }), [ detailReplacementReferenceCards, isDetailResultModeGenerating, latestReplacementResults, normalizedDeletedResultCollections.detail_replacement, ] ); const skuResultCards = useMemo( () => skuReplacementReferenceCards.map((card, index) => { const resultMeta = latestReplacementResults[index]; const resultUrl = resultMeta?.url && !normalizedDeletedResultCollections.sku_replacement.includes(resultMeta.url) ? resultMeta.url : null; return { ...card, index, resultUrl, resultText: resultUrl ? resultMeta?.text || "" : "", status: getReplacementCardStatus({ index, resultUrl, resultText: resultUrl ? resultMeta?.text || "" : "", generatedCount: latestReplacementResults.length, isGenerating: isSkuResultModeGenerating, }), }; }), [ isSkuResultModeGenerating, latestReplacementResults, normalizedDeletedResultCollections.sku_replacement, 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 detailGenerationExpectedCount = useMemo(() => { if ( pendingGeneration?.modeType === "detail_generation" && Number.isFinite(pendingGeneration.expectedCount) && pendingGeneration.expectedCount > 0 ) { return pendingGeneration.expectedCount; } const progressTotal = Number(detailGenerationProgress.total || 0); if (progressTotal > 1) { return Math.max( detailGenerationRunResults.length, Math.round((progressTotal - 1) / 2) ); } return detailGenerationRunResults.length; }, [ detailGenerationProgress.total, detailGenerationRunResults.length, pendingGeneration?.expectedCount, pendingGeneration?.modeType, ]); const hasDetailGenerationPlaceholderState = currentResultModeType === "detail_generation" && detailGenerationExpectedCount > detailGenerationRunResults.length && (pendingGeneration?.modeType === "detail_generation" || detailGenerationProgress.status === "running" || detailGenerationProgress.status === "error"); const gridRequestedImageCount = activeResultModeType === "detail_generation" ? Math.max( 1, detailGenerationExpectedCount || activeResultModeUrls.length ) : Math.max(1, imageParams.imageCount || 1); const gridPendingRemainingCount = activeResultModeType === "scene_generation" && pendingGeneration?.modeType === "scene_generation" ? Math.max(pendingGeneration.expectedCount - currentSceneGeneratedUrls.length, 0) : activeResultModeType === "detail_generation" && hasDetailGenerationPlaceholderState ? Math.max( detailGenerationExpectedCount - detailGenerationRunResults.length, 0 ) : 0; const gridPendingCardCount = activeResultModeType === "scene_generation" || activeResultModeType === "detail_generation" ? gridPendingRemainingCount : 0; const gridResultCardCount = Math.max( activeResultModeUrls.length + gridPendingCardCount, (activeResultModeType === "scene_generation" || activeResultModeType === "detail_generation") && pendingGeneration?.modeType === activeResultModeType ? activeResultModeUrls.length + gridPendingRemainingCount : activeResultModeType === currentResultModeType ? gridRequestedImageCount : activeResultModeUrls.length, 1 ); const gridResultCards = useMemo( () => { const placeholderStartIndex = activeResultModeUrls.length; return Array.from({ length: gridResultCardCount }, (_, index) => { const url = activeResultModeUrls[index] || null; const placeholderOffset = Math.max(index - placeholderStartIndex, 0); return { id: url || `grid-result-placeholder-${activeResultModeType}-${index}`, index, url, status: url ? ("completed" as const) : activeResultModeType === "detail_generation" && detailGenerationProgress.status === "error" && gridPendingRemainingCount > 0 ? ("missing" as const) : gridPendingRemainingCount > 0 ? placeholderOffset === 0 ? ("generating" as const) : ("queued" as const) : ("waiting" as const), }; }); }, [ activeResultModeType, activeResultModeUrls, detailGenerationProgress.status, gridPendingRemainingCount, gridResultCardCount, ] ); const shouldShowGridResultSection = activeResultModeUrls.length > 0 || ((activeResultModeType === "scene_generation" && isSceneResultModeGenerating) || (activeResultModeType === "detail_generation" && (isDetailGenerationResultModeGenerating || hasDetailGenerationPlaceholderState))); 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 || filledProductAttachmentCount >= requiredProductUploadCount; 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(() => { setActiveResultModeType(currentResultModeType); }, [currentResultModeType]); useEffect(() => { if (!pendingGeneration) { return; } if (pendingGenerationProgressCount >= pendingGeneration.expectedCount) { setPendingGeneration(null); } }, [pendingGeneration, pendingGenerationProgressCount]); useEffect(() => { if (!pendingGeneration || transportIsGenerating) { return; } if (!hasAssistantFailureAfterLastUser) { return; } const hasProgress = pendingGeneration.modeType === "scene_generation" ? currentSceneGeneratedUrls.length > 0 : pendingGeneration.modeType === "detail_generation" ? detailGenerationRunResults.length > 0 : latestReplacementResults.length > 0; if ( pendingGeneration.modeType === "scene_generation" && !hasProgress && Date.now() - pendingGeneration.timestamp < AGENT_SCENE_PENDING_FAILURE_GRACE_MS ) { return; } if (!hasProgress) { setPendingGeneration(null); } }, [ currentSceneGeneratedUrls.length, detailGenerationRunResults.length, hasAssistantFailureAfterLastUser, latestReplacementResults.length, pendingGeneration, transportIsGenerating, ]); useEffect(() => { onResultPreviewUrlsChange?.( currentResultModeType, mergedResultCollections[currentResultModeType] || [] ); }, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]); useEffect(() => { if (activeResultModeUrls.length === 0) { if (selectedResultUrl) { setSelectedResultUrl(null); } return; } if (!selectedResultUrl || !activeResultModeUrls.includes(selectedResultUrl)) { setSelectedResultUrl(activeResultModeUrls[0] || null); } }, [activeResultModeUrls, 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 (isDetailReplacementPreset && isDetailAiGenerationMethod) { void handleRunAiDetailGeneration(); return; } if (!selectedAgentPresetId) { toast.error("请先选择生图模式"); return; } if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) { toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`); return; } if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); return; } const uploadedProductAttachmentCount = selectedProductAttachments.length; if (hasAgentUploadSlots && uploadedProductAttachmentCount < requiredProductUploadCount) { toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张产品图`); return; } const resolvedModelId = selectedModelId !== "auto" ? selectedModelId : selectedAgentPreset?.defaultModelId || selectedModelId; if (!resolvedModelId) { toast.error("当前智能体未配置模型"); return; } if ( resolvedModelId !== "auto" && !selectableModels.some((model) => model.id === resolvedModelId) ) { toast.error("当前智能体绑定的模型不存在或已停用,请重新编辑智能体模型配置"); return; } const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = []; const sanitizedAttachments = sceneMessageAttachments; if ( hasAgentUploadSlots && selectedProductAttachments.filter((attachment) => Boolean(attachment?.url)).length < requiredProductUploadCount ) { toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张有效产品图`); return; } const primaryAttachmentCountForMessage = Math.min( scenePrimaryReferenceCount, sanitizedAttachments.length ); const secondaryAttachmentCountForMessage = Math.min( sceneSecondaryReferenceCount, Math.max( sanitizedAttachments.length - primaryAttachmentCountForMessage - (selectedModelAttachment ? 1 : 0), 0 ) ); sanitizedAttachments.forEach((attachment, index) => { if (!attachment) return; let attachmentName = normalizeChatAttachmentName(attachment.name); if (isSceneGenerationPreset) { if (index < primaryAttachmentCountForMessage) { attachmentName = `主SKU参考图 ${index + 1}`; } else if (index < primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage) { attachmentName = `搭配SKU参考图 ${index - primaryAttachmentCountForMessage + 1}`; } else if ( selectedModelAttachment && index === primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage ) { attachmentName = "品牌模特图"; } } parts.push({ type: "file", mediaType: normalizeChatMediaType(attachment.contentType), name: attachmentName, url: attachment.url, assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, }); }); const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; let textContent = input.trim(); if (!textContent && selectedAgentPreset?.type === "text" && resolvedWorkflowContext?.summaryText) { textContent = `请基于当前工作流设定生成方案:${resolvedWorkflowContext.summaryText}`; } if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) { textContent = `请按「${selectedAgentPreset.name}」输出内容`; } textContent = clampChatRequestText(textContent, MAX_CHAT_TEXT_PART_LENGTH); if (textContent) { parts.push({ type: "text", text: textContent }); } if (parts.length === 0) { toast.error("请至少补充要求或上传图片"); return; } const nextHiddenPrompt = resolvedWorkflowContext?.promptText ? clampChatRequestText( `【工作流设定】\n${resolvedWorkflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`, MAX_CHAT_HIDDEN_PROMPT_LENGTH ) : null; hiddenPromptRef.current = nextHiddenPrompt; generationRequestSnapshotRef.current = { hiddenPrompt: nextHiddenPrompt, }; setActiveResultModeType(currentResultModeType); setSelectedResultUrl(null); setPendingGeneration({ chatId, modeType: currentResultModeType, expectedCount: currentResultModeType === "scene_generation" ? Math.max(1, imageParams.imageCount || 1) : currentResultModeType === "detail_replacement" ? detailReplacementReferenceCards.length : skuReplacementReferenceCards.length, userTurnCountAtStart: userMessageCount, timestamp: Date.now(), }); try { sendMessage({ role: "user", parts }); if (selectedAgentPreset?.type !== "image") { setInput(""); } } catch (error) { generationRequestSnapshotRef.current = { hiddenPrompt: null, }; setPendingGeneration(null); toast.error(error instanceof Error ? error.message : "发送生成请求失败"); } }, [ attachments, detailReplacementReferenceCards.length, handleRunAiDetailGeneration, currentResultModeType, hasAgentUploadSlots, imageParams.imageCount, input, isDetailAiGenerationMethod, isDetailReplacementPreset, isSkuReplacementPreset, requiredProductUploadCount, selectedAgentPreset, selectedAgentPresetId, selectableModels, selectedModelId, sendMessage, setPendingGeneration, skuReplacementReferenceCards.length, userMessageCount, getLatestWorkflowContext, isSceneGenerationPreset, workflowContext, sceneMessageAttachments, scenePrimaryReferenceCount, sceneSecondaryReferenceCount, selectedModelAttachment, ]); const handleStopGeneration = useCallback(() => { detailGenerationCancelRef.current = true; stop(); setPendingGeneration(null); 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 uploadBase64ResultToStorage = useCallback(async (base64Url: string) => { const response = await fetch(base64Url); const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `agent-scene-resize-${Date.now()}.png`); const uploadResponse = await fetchUploadWithRetry(formData); if (!uploadResponse.ok) { throw new Error("上传失败"); } const result = await uploadResponse.json().catch(() => ({})); const uploadedUrl = String(result?.url || "").trim(); if (!/^https?:\/\//i.test(uploadedUrl)) { throw new Error("上传结果无效"); } return uploadedUrl; }, []); const handleSceneResultResizeComplete = useCallback( async (resizedImageUrl: string) => { if (!onResultPreviewUrlsChange) { toast.error("当前项目未接入结果保存能力"); return; } let finalUrl = String(resizedImageUrl || "").trim(); if (finalUrl.startsWith("data:")) { try { toast.info("正在上传尺寸调整结果..."); finalUrl = await uploadBase64ResultToStorage(finalUrl); } catch (error) { console.error("[AgentGenerationWorkspaceDarkV2] 上传尺寸调整结果失败:", error); toast.error("尺寸调整结果上传失败"); return; } } if (!/^https?:\/\//i.test(finalUrl)) { toast.error("尺寸调整结果地址无效"); return; } const nextSceneUrls = Array.from( new Set([ ...normalizeResultCollectionUrls(mergedResultCollections.scene_generation), finalUrl, ]) ); onResultPreviewUrlsChange("scene_generation", nextSceneUrls); setActiveResultModeType("scene_generation"); setSelectedResultUrl(finalUrl); setSceneResizeEditorTargetUrl(null); toast.success("尺寸调整结果已加入场景结果区"); }, [mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage] ); const appendResultUrlToCollection = useCallback( (modeType: AgentGenerationResultModeType, imageUrl: string) => { const normalizedUrl = String(imageUrl || "").trim(); if (!normalizedUrl || !onResultPreviewUrlsChange) { return false; } const nextUrls = Array.from( new Set([ ...normalizeResultCollectionUrls(mergedResultCollections[modeType]), normalizedUrl, ]) ); onResultPreviewUrlsChange(modeType, nextUrls); if (activeResultModeType === modeType) { setSelectedResultUrl(normalizedUrl); } return true; }, [activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange] ); const handlePsdDerivedImageCreated = useCallback( (imageUrl: string) => { appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl); }, [appendResultUrlToCollection, psdTemplateDialogResultModeType] ); const handlePsdRenderedResultCreated = useCallback( (imageUrl: string) => { appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl); }, [appendResultUrlToCollection, psdTemplateDialogResultModeType] ); const handleDeleteResultImage = useCallback( (modeType: AgentGenerationResultModeType, imageUrl: string) => { const normalizedUrl = String(imageUrl || "").trim(); if (!normalizedUrl || !onDeleteResultImage) { return; } if (selectedResultUrl === normalizedUrl) { setSelectedResultUrl(null); } if (fullscreenPreview === normalizedUrl) { setFullscreenPreview(null); } if (sceneResizeEditorTargetUrl === normalizedUrl) { setSceneResizeEditorTargetUrl(null); } onDeleteResultImage(modeType, normalizedUrl); toast.success("结果图已删除"); }, [fullscreenPreview, onDeleteResultImage, sceneResizeEditorTargetUrl, selectedResultUrl] ); 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") && !isDetailAiGenerationMethod && 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, isDetailAiGenerationMethod, slotRequirementMessage, ]); const updateDetailReplacementState = useCallback( ( updater: | Partial | ((current: AgentModeWorkspaceState["detailReplacement"]) => AgentModeWorkspaceState["detailReplacement"]) ) => { const current = resolvedAgentModeState.detailReplacement; const nextDetailReplacement = typeof updater === "function" ? updater(current) : { ...current, ...updater }; if (nextDetailReplacement === current) { return; } 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 }; if (nextSkuReplacement === current) { return; } emitAgentModeState({ ...resolvedAgentModeState, skuReplacement: nextSkuReplacement, }); }, [emitAgentModeState, resolvedAgentModeState] ); useEffect(() => { if (!isDetailReplacementPreset) { lastSyncedDetailProductSlotKeysRef.current = []; lastSyncedDetailBundleSlotKeysRef.current = []; lastSyncedDetailModelSlotKeyRef.current = null; return; } const secondaryReferenceImages = referenceImages .filter((image) => image.source === "secondary") .slice(0, AGENT_DETAIL_BUNDLE_SLOT_INDEXES.length); const nextDetailProductSourceKeys = detailGenerationProductSlotIndexes.map((_, index) => getAttachmentIdentityKey(selectedProductAttachments[index]) ); const nextDetailBundleSourceKeys = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.map((_, index) => getAttachmentIdentityKey( secondaryReferenceImages[index] ? { url: secondaryReferenceImages[index]?.url, assetId: secondaryReferenceImages[index]?.assetId || null, } : null ) ); const nextDetailModelSourceKey = modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 ? getAttachmentIdentityKey(selectedModelAttachment) : null; updateDetailReplacementState((current) => { let changed = false; const nextBrief = { ...(current.brief || createEmptyAgentDetailBriefForm()), }; const workflowPrimarySku = String(workflowContext?.primarySku || "").trim(); const workflowPriceBand = String(workflowContext?.priceBand || "").trim(); if (!nextBrief.productSpu && workflowPrimarySku) { nextBrief.productSpu = workflowPrimarySku; changed = true; } if (!nextBrief.priceBand && workflowPriceBand) { nextBrief.priceBand = workflowPriceBand; changed = true; } const nextSlots = Array.from( { length: AGENT_DETAIL_SLOT_CONFIGS.length }, (_, index) => current.slots?.[index] || null ); detailGenerationProductSlotIndexes.forEach((slotIndex, index) => { const attachment = selectedProductAttachments[index]; const currentSlot = nextSlots[slotIndex]; const currentSlotKey = getAttachmentIdentityKey(currentSlot); const previousSyncedKey = lastSyncedDetailProductSlotKeysRef.current[index] || null; const currentIsPreviousAutoSelection = Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey; if (attachment) { const nextAttachment = { url: attachment.url, name: attachment.name || AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || null, contentType: attachment.contentType || "image/jpeg", assetId: attachment.assetId || null, }; if ( (!currentSlot || currentIsPreviousAutoSelection) && !areAttachmentValuesEqual(currentSlot, nextAttachment) ) { nextSlots[slotIndex] = nextAttachment; changed = true; } } else if (currentIsPreviousAutoSelection) { nextSlots[slotIndex] = null; changed = true; } }); if ( modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 && selectedModelAttachment ) { const currentSlot = nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX]; const currentSlotKey = getAttachmentIdentityKey(currentSlot); const previousSyncedKey = lastSyncedDetailModelSlotKeyRef.current; const currentIsPreviousAutoSelection = Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey; const nextAttachment = { url: selectedModelAttachment.url, name: selectedModelAttachment.name || AGENT_DETAIL_SLOT_CONFIGS[AGENT_DETAIL_MODEL_SLOT_INDEX]?.label || null, contentType: selectedModelAttachment.contentType || "image/jpeg", assetId: selectedModelAttachment.assetId || null, }; if ( (!currentSlot || currentIsPreviousAutoSelection) && !areAttachmentValuesEqual(currentSlot, nextAttachment) ) { nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] = nextAttachment; changed = true; } } else if (modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0) { const currentSlot = nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX]; const currentSlotKey = getAttachmentIdentityKey(currentSlot); const previousSyncedKey = lastSyncedDetailModelSlotKeyRef.current; const currentIsPreviousAutoSelection = Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey; if (currentIsPreviousAutoSelection) { nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] = null; changed = true; } } AGENT_DETAIL_BUNDLE_SLOT_INDEXES.forEach((slotIndex, index) => { const image = secondaryReferenceImages[index]; const currentSlot = nextSlots[slotIndex]; const currentSlotKey = getAttachmentIdentityKey(currentSlot); const previousSyncedKey = lastSyncedDetailBundleSlotKeysRef.current[index] || null; const currentIsPreviousAutoSelection = Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey; if (image) { const nextAttachment = { url: image.url, name: image.name || image.label || AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || null, contentType: image.mimeType || "image/jpeg", assetId: image.assetId || null, }; if ( (!currentSlot || currentIsPreviousAutoSelection) && !areAttachmentValuesEqual(currentSlot, nextAttachment) ) { nextSlots[slotIndex] = nextAttachment; changed = true; } } else if (currentIsPreviousAutoSelection) { nextSlots[slotIndex] = null; changed = true; } }); if (!changed) { lastSyncedDetailProductSlotKeysRef.current = nextDetailProductSourceKeys; lastSyncedDetailBundleSlotKeysRef.current = nextDetailBundleSourceKeys; lastSyncedDetailModelSlotKeyRef.current = nextDetailModelSourceKey; return current; } lastSyncedDetailProductSlotKeysRef.current = nextDetailProductSourceKeys; lastSyncedDetailBundleSlotKeysRef.current = nextDetailBundleSourceKeys; lastSyncedDetailModelSlotKeyRef.current = nextDetailModelSourceKey; return { ...current, brief: nextBrief, slots: nextSlots, }; }); }, [ detailGenerationProductSlotIndexes, isDetailReplacementPreset, modelSlotEnabled, referenceImages, selectedModelAttachment, selectedProductAttachments, updateDetailReplacementState, workflowContext?.priceBand, workflowContext?.primarySku, ]); const updateDetailGenerationMethod = useCallback( (method: "reference_replace" | "ai_detail_generate") => { updateDetailReplacementState((current) => ({ ...current, method, })); }, [updateDetailReplacementState] ); const updateDetailGenerationBriefField = useCallback( (key: keyof AgentDetailBriefForm, value: string) => { updateDetailReplacementState((current) => ({ ...current, brief: { ...(current.brief || createEmptyAgentDetailBriefForm()), [key]: value, }, })); }, [updateDetailReplacementState] ); const updateDetailGenerationSlot = useCallback( (slotIndex: number, attachment: PickedAttachment | null) => { updateDetailReplacementState((current) => { const nextSlots = Array.from( { length: AGENT_DETAIL_SLOT_CONFIGS.length }, (_, index) => current.slots?.[index] || null ); nextSlots[slotIndex] = attachment ? { url: attachment.url, name: attachment.name || null, contentType: attachment.contentType || null, assetId: attachment.assetId || null, } : null; return { ...current, slots: nextSlots, }; }); }, [updateDetailReplacementState] ); const updateDetailGenerationProgressState = useCallback( (progress: AgentDetailGenerationProgressState) => { updateDetailReplacementState((current) => ({ ...current, progress, })); }, [updateDetailReplacementState] ); const handleDetailGenerationSlotFileChange = 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; } 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 || "上传失败"); } updateDetailGenerationSlot(slotIndex, { url: data.url, name: file.name, contentType: file.type || "image/jpeg", assetId: typeof data.assetId === "string" ? data.assetId : undefined, }); toast.success(`${AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材"}上传成功`); } catch (error) { toast.error(error instanceof Error ? error.message : "上传失败"); } finally { event.currentTarget.value = ""; } }, [updateDetailGenerationSlot] ); async function handleRunAiDetailGeneration() { if (!selectedAgentPresetId) { toast.error("请先选择生图模式"); return; } if (detailGenerationMode !== "classic") { toast.error("AGENT排版模式将在共享画布文字层能力接入后开放,当前请先使用经典详情"); return; } if (detailGenerationValidationIssues.length > 0) { toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数"); return; } const productAttachments = detailGenerationProductSlotIndexes.map((index) => detailGenerationSlots[index] ).filter((item): item is NonNullable<(typeof detailGenerationSlots)[number]> => Boolean(item?.url)); const bundleAttachments = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.map((index) => detailGenerationSlots[index] ).filter((item): item is NonNullable<(typeof detailGenerationSlots)[number]> => Boolean(item?.url)); const modelAttachment = modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 ? detailGenerationSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] : null; const basePrompt = detailGenerationBrief.productName ? `${detailGenerationBrief.productName}详情页生成` : "电商详情页生成"; const demandHint = detailGenerationBrief.demandType ? `,需求类型:${detailGenerationBrief.demandType}` : ""; const userSupplementPrompt = input.trim(); const detailPrompt = userSupplementPrompt || `${basePrompt},面向${detailGenerationBrief.targetAudience},在${detailGenerationBrief.usageScenario}场景解决${detailGenerationBrief.coreNeed}${demandHint}`; const workflowHint = workflowContext?.promptText ? `\n\n【工作流风格要求】\n${workflowContext.promptText}` : ""; const detailPromptWithWorkflow = `${detailPrompt}${workflowHint}`.trim(); const detailCount = extractAgentDetailFrameCount(detailPrompt); const referenceImageNotes = detailGenerationVisibleSlotIndexes.map((slotIndex, visibleIndex) => { const config = AGENT_DETAIL_SLOT_CONFIGS[slotIndex]; const current = detailGenerationSlots[slotIndex]; return `${visibleIndex + 1}. ${config?.label || `素材${visibleIndex + 1}`}${current?.name ? `(${current.name})` : ""}`; }); const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPromptWithWorkflow) ? "en" : "zh"; const resolvedModelId = selectedModelId !== "auto" ? selectedModelId : selectedAgentPreset?.defaultModelId || modelsData?.defaultModelId || undefined; detailGenerationCancelRef.current = false; setDetailGenerationRunResults([]); setActiveResultModeType("detail_generation"); setSelectedResultUrl(null); const startedAt = new Date().toISOString(); updateDetailGenerationProgressState({ status: "running", stage: "Agent 正在解析需求并规划详情结构...", current: 0, total: Math.max(1, detailCount * 2 + 1), startedAt, finishedAt: null, }); setPendingGeneration({ chatId, modeType: "detail_generation", expectedCount: detailCount, userTurnCountAtStart: userMessageCount, timestamp: Date.now(), }); try { const planResponse = await fetch("/api/canvas/detail-plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: detailPromptWithWorkflow, count: detailCount, generationMode: "classic", referenceImages: detailGenerationSlots .filter((_, slotIndex) => detailGenerationVisibleSlotIndexes.includes(slotIndex)) .map((slot) => slot?.url || "") .filter(Boolean) .slice(0, 12), referenceImageNotes, modelImageUrl: modelAttachment?.url || undefined, bundleImageUrls: bundleAttachments.map((item) => item.url), outputLanguage, brief: detailGenerationBrief, }), }); const planResult = await planResponse.json().catch(() => ({})); if (!planResponse.ok || !planResult?.success) { throw new Error(planResult?.error || "详情分镜规划失败"); } const planData = (planResult.data || {}) as { summary?: string; prompts?: string[]; globalStyle?: Record; screens?: Array>; }; const prompts = Array.isArray(planData.prompts) ? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean) : []; if (prompts.length === 0) { throw new Error("未获取到有效分镜提示词"); } setPendingGeneration((current) => current && current.chatId === chatId && current.modeType === "detail_generation" ? { ...current, expectedCount: prompts.length, } : current ); updateDetailGenerationProgressState({ status: "running", stage: "分镜规划完成,开始逐屏生成...", current: 1, total: Math.max(1, prompts.length * 2 + 1), startedAt, finishedAt: null, }); const generatedImages: DirectDetailGenerationImage[] = []; for (let index = 0; index < prompts.length; index += 1) { if (detailGenerationCancelRef.current) { throw new Error("已停止当前 AI详情生成任务"); } const screen = Array.isArray(planData.screens) ? planData.screens[index] || {} : {}; const useModelForScene = Boolean(screen?.useModel); const bundleShowcase = Boolean(screen?.bundleShowcase); const bundleReason = String(screen?.bundleReason || "").trim(); const promptText = String(screen?.visualPrompt || prompts[index] || "").trim(); const generationStepCurrent = 2 + index * 2; const reviewStepCurrent = generationStepCurrent + 1; updateDetailGenerationProgressState({ status: "running", stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`, current: generationStepCurrent, total: Math.max(1, prompts.length * 2 + 1), startedAt, finishedAt: null, }); const globalStyle = planData.globalStyle || {}; const styleDirective = [ globalStyle.designLanguage ? `全局风格:${globalStyle.designLanguage}` : "", globalStyle.typography?.fontFamily ? `字体家族统一:${globalStyle.typography.fontFamily}` : "", globalStyle.typography?.title ? `标题规范:${globalStyle.typography.title}` : "", globalStyle.typography?.subtitle ? `副标题规范:${globalStyle.typography.subtitle}` : "", globalStyle.typography?.body ? `正文规范:${globalStyle.typography.body}` : "", globalStyle.layout?.grid ? `布局栅格:${globalStyle.layout.grid}` : "", globalStyle.layout?.safeArea ? `安全区:${globalStyle.layout.safeArea}` : "", globalStyle.layout?.rhythm ? `留白节奏:${globalStyle.layout.rhythm}` : "", globalStyle.palette ? `配色:${globalStyle.palette}` : "", ] .filter(Boolean) .join(";"); const styleAnchorUrl = index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null; const generationReferenceImagesBase = [ ...productAttachments.map((item) => item.url), ...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []), ...(useModelForScene && modelAttachment?.url ? [modelAttachment.url] : []), ...(styleAnchorUrl ? [styleAnchorUrl] : []), ].filter(Boolean); const generationPrompt = sanitizeDetailImageModelPrompt( [ promptText, styleDirective ? `全局样式约束:${styleDirective}` : "", screen?.screenType ? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)` : "", index === 0 ? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。" : "", useModelForScene ? "人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。" : "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。", screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "", bundleShowcase ? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。" : "", bundleReason ? `搭配依据:${bundleReason}` : "", screen?.title ? `本屏标题:${screen.title}` : "", screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "", screen?.copy ? `本屏文案说明:${screen.copy}` : "", screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "", screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "", screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "", screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "", screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "", `输出为竖版电商详情图,尺寸固定 ${AGENT_DETAIL_TARGET_WIDTH}x${AGENT_DETAIL_TARGET_HEIGHT} 像素。`, "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。", ] .filter(Boolean) .join("\n\n") ); const firstPass = await requestCanvasImageGeneration({ prompt: generationPrompt, model: resolvedModelId, aspectRatio: "9:16", imageSize: imageParams.imageSize, imageCount: 1, negativePrompt: imageParams.negativePrompt, referenceImage: productAttachments[0]?.url, referenceImages: generationReferenceImagesBase, }); const firstPassImage = firstPass.images[0]; if (!firstPassImage?.originalUrl && !firstPassImage?.thumbnailUrl) { throw new Error(`第 ${index + 1} 屏生成失败`); } let finalImage = { originalUrl: String( firstPassImage.originalUrl || firstPassImage.thumbnailUrl || "" ).trim(), thumbnailUrl: String( firstPassImage.thumbnailUrl || firstPassImage.originalUrl || "" ).trim(), }; updateDetailGenerationProgressState({ status: "running", stage: `第 ${index + 1}/${prompts.length} 屏首稿完成,正在审稿优化...`, current: reviewStepCurrent, total: Math.max(1, prompts.length * 2 + 1), startedAt, finishedAt: null, }); try { const reviewResponse = await fetch("/api/canvas/detail-review", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ outputLanguage, generationMode: "classic", imageUrl: finalImage.originalUrl || finalImage.thumbnailUrl, referenceImages: generationReferenceImagesBase.slice(0, 6), brief: { productName: detailGenerationBrief.productName, targetAudience: detailGenerationBrief.targetAudience, usageScenario: detailGenerationBrief.usageScenario, coreNeed: detailGenerationBrief.coreNeed, coreSellingPoint: detailGenerationBrief.coreSellingPoint, }, globalStyle: planData.globalStyle, screen: { index: index + 1, title: String(screen?.title || "").trim(), subtitle: String(screen?.subtitle || "").trim(), copy: String(screen?.copy || "").trim(), layoutNote: String(screen?.layoutNote || "").trim(), visualPrompt: generationPrompt, screenType: String(screen?.screenType || "").trim(), }, }), }); const reviewResult = (await reviewResponse.json().catch(() => ({}))) as { success?: boolean; data?: DirectDetailReviewResult; }; if ( reviewResponse.ok && reviewResult?.success && reviewResult.data?.reviewAction === "regenerate_image" && typeof reviewResult.data?.revisedPrompt === "string" && reviewResult.data.revisedPrompt.trim().length > 20 ) { const regenerated = await requestCanvasImageGeneration({ prompt: sanitizeDetailImageModelPrompt(reviewResult.data.revisedPrompt.trim()), model: resolvedModelId, aspectRatio: "9:16", imageSize: imageParams.imageSize, imageCount: 1, negativePrompt: imageParams.negativePrompt, referenceImage: productAttachments[0]?.url, referenceImages: [ ...generationReferenceImagesBase, finalImage.originalUrl || finalImage.thumbnailUrl, ].filter(Boolean), }); const regeneratedImage = regenerated.images[0]; if (regeneratedImage?.originalUrl || regeneratedImage?.thumbnailUrl) { finalImage = { originalUrl: String( regeneratedImage.originalUrl || regeneratedImage.thumbnailUrl || "" ).trim(), thumbnailUrl: String( regeneratedImage.thumbnailUrl || regeneratedImage.originalUrl || "" ).trim(), }; } } } catch (reviewError) { console.warn("[AgentGenerationWorkspaceDarkV2] AI detail review failed:", reviewError); } const nextImage: DirectDetailGenerationImage = { originalUrl: finalImage.originalUrl, thumbnailUrl: finalImage.thumbnailUrl, title: String(screen?.title || "").trim() || `详情图 ${index + 1}`, subtitle: String(screen?.subtitle || "").trim(), copy: String(screen?.copy || "").trim(), }; generatedImages.push(nextImage); setDetailGenerationRunResults([...generatedImages]); } updateDetailGenerationProgressState({ status: "done", stage: `AI详情生成完成,共 ${generatedImages.length}/${prompts.length} 屏`, current: Math.max(1, prompts.length * 2 + 1), total: Math.max(1, prompts.length * 2 + 1), startedAt, finishedAt: new Date().toISOString(), }); toast.success(`AI详情生成完成,已输出 ${generatedImages.length} 屏详情图`); } catch (error) { const message = error instanceof Error ? error.message : "AI详情生成失败"; updateDetailGenerationProgressState({ status: /已停止/.test(message) ? "idle" : "error", stage: message, current: detailGenerationRunResults.length, total: pendingGeneration?.expectedCount || detailCount, startedAt, finishedAt: new Date().toISOString(), }); toast.error(message); } finally { setPendingGeneration(null); } } 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 aiDetailWorkbenchPanel = isDetailAiGenerationMethod ? (

AI详情工作台

复用无限画布中的 AI详情生成链路,直接完成分镜规划和逐屏详情生图。

{detailGenerationProgress.status === "running" ? "生成中" : detailGenerationProgress.status === "done" ? "已完成" : detailGenerationProgress.status === "error" ? "异常" : "待启动"}
经典详情
生成进度
{detailGenerationProgress.current}/{detailGenerationProgress.total || 0}
0 ? Math.min( 100, Math.round( (detailGenerationProgress.current / detailGenerationProgress.total) * 100 ) ) : 0 }%`, }} />
{detailGenerationProgress.stage || "请先补齐核心参数和素材后开始生成"}
{detailGenerationValidationIssues.length > 0 ? (
{detailGenerationValidationIssues.map((issue) => ( {issue} ))}
) : null}
核心参数
{([ ["productSpu", "商品SPU"], ["productName", "产品名称"], ["targetAudience", "主购买人群 *"], ["usageScenario", "需求场景 *"], ["coreNeed", "核心需求 *"], ["priceBand", "价格带"], ["demandType", "需求类型"], ["brandTone", "品牌语气"], ] as Array<[keyof AgentDetailBriefForm, string]>).map(([key, label]) => (
updateDetailGenerationBriefField(key, event.target.value) } placeholder={AGENT_DETAIL_FIELD_EXAMPLES[key]} className="h-10 rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30" />
))}
updateDetailGenerationBriefField("coreSellingPoint", event.target.value) } placeholder={AGENT_DETAIL_FIELD_EXAMPLES.coreSellingPoint} className="h-10 rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30" />
updateDetailGenerationBriefField("competitorReference", event.target.value) } placeholder={AGENT_DETAIL_FIELD_EXAMPLES.competitorReference} className="h-10 rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30" />