"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type SyntheticEvent, } from "react"; import useSWR from "swr"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowUp, CheckCircle2, RefreshCw, 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 { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor"; 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 { defaultAgentWorkflowBusinessModeSettings, normalizeAgentWorkflowBusinessModeSettings, normalizeAgentWorkflowPageFeatureSettings, } from "@/lib/agents/workflow-ui-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, AgentDetailReplacementStrategy, AgentGenerationProjectWorkspaceState, AgentGenerationProjectResultCollections, AgentGenerationResultModeType, AgentModeReferenceCandidate, AgentModeWorkspaceState, AgentPreset, AgentTaskDetailReferenceSource, AgentWorkflowShowcaseConfig, AgentWorkflowComposerContext, AgentWorkflowBusinessModeKey, AgentWorkflowBusinessModeConfig, AgentWorkflowBusinessModeSettings, AgentWorkflowMaterialQueryType, AgentWorkflowProductDetailChannel, AgentWorkflowReferenceQueryType, AgentWorkflowPageFeatureSettings, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; import { createEmptyAgentModeWorkspaceState, } from "@/lib/agents/mode-workspace-state"; import { createEmptyAgentGenerationProjectWorkspaceState, normalizeAgentGenerationProjectPersistedState, } from "@/lib/agents/generation-project-state"; export interface AgentGenerationWorkspaceDarkV2Props { chatId: string; projectId: string; selectedAgentPresetId?: string | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; workflowContext?: AgentWorkflowComposerContext | null; workflowBusinessModeSettings?: AgentWorkflowBusinessModeSettings | null; workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null; workflowPromptOptimizationEnabled?: boolean; getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null; onBack?: () => void; brandStorePanel?: ReactNode; materialSelectionPanel?: ReactNode; brandModelPanel?: ReactNode; 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 AgentWorkflowModelOption { id: string; displayName: string; providerType: string; supportsImageGen?: boolean; isMultimodal?: boolean; imageGenConfig?: any; } 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 DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4; const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ value: AgentWorkflowProductDetailChannel; label: string; }> = [ { value: "taobao", label: "淘宝" }, { value: "1688", label: "1688" }, { value: "douyin", label: "抖音" }, { value: "jd", label: "京东" }, ]; 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; replacementCardId?: string | null; } interface AgentModeReferenceSearchResponse { success?: boolean; data?: { query: string; queryType: AgentWorkflowReferenceQueryType; target: AgentTaskDetailReferenceSource; matchedSpu?: string | null; matchedSku?: string | null; brandName?: string | null; categoryName?: string | null; shopName?: string | null; channel?: AgentWorkflowProductDetailChannel | null; candidateCount: number; candidates: AgentModeReferenceCandidate[]; selectedCandidateId?: string | null; references: AgentModeWorkspaceState["detailReplacement"]["references"]; }; error?: { code?: string; message?: string; }; } const AI_DETAIL_GENERATION_DISABLED_MESSAGE = "开发中"; const IS_AI_DETAIL_GENERATION_DISABLED = true; const DEFAULT_DETAIL_REPLACEMENT_STRATEGY: AgentDetailReplacementStrategy = "scene_replace"; function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSource) { return source === "main" ? "主图" : source === "sku" ? "SKU图" : "详情图"; } function getResultModeTypeByBusinessMode( mode: AgentWorkflowBusinessModeConfig | null | undefined, method: "reference_replace" | "ai_detail_generate" ): AgentGenerationResultModeType { if (mode?.key !== "detail") { return "scene_generation"; } const canReferenceReplace = mode.referenceReplaceEnabled !== false; const canAiDetailGenerate = mode.aiDetailGenerateEnabled !== false && !IS_AI_DETAIL_GENERATION_DISABLED; if (method === "ai_detail_generate" && canAiDetailGenerate) { return "detail_generation"; } if (canReferenceReplace) { return "detail_replacement"; } return canAiDetailGenerate ? "detail_generation" : "detail_replacement"; } 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" }); } const AGENT_DEFERRED_SECTION_STYLE = { contentVisibility: "auto", containIntrinsicSize: "1px 760px", } as CSSProperties; const AGENT_DEFERRED_RESULTS_STYLE = { contentVisibility: "auto", containIntrinsicSize: "1px 900px", } as CSSProperties; const AGENT_DEFERRED_LIST_CARD_STYLE = { contentVisibility: "auto", containIntrinsicSize: "1px 320px", } as CSSProperties; 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(/\n*\n*/g, "") .replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "") .trim(); } function extractReplacementCardIdFromAssistantText(value: string) { const match = String(value || "").match(//); return match?.[1]?.trim() || null; } 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; const AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT = 4; function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } async function runAgentBoundedParallel( items: T[], workerCount: number, worker: (item: T, index: number) => Promise ) { const maxWorkers = Math.max(1, Math.min(workerCount, items.length)); let cursor = 0; let firstError: unknown = null; const workers = Array.from({ length: maxWorkers }, async () => { while (cursor < items.length) { const index = cursor; cursor += 1; if (firstError) { return; } try { await worker(items[index]!, index); } catch (error) { firstError = firstError || error; } } }); await Promise.all(workers); if (firstError) { throw firstError; } } function compactDirectDetailGenerationImages( images: Array ) { return images.filter( (item): item is DirectDetailGenerationImage => Boolean(item?.originalUrl || item?.thumbnailUrl) ); } 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, previewUrl: url, candidateId: candidate.id, sortOrder: index, passthrough: false, assignedProductIndex: params.assignProductIndex === true && (params.productCount || 0) > 0 ? Math.min(index, Math.max((params.productCount || 1) - 1, 0)) : null, })); } function readLocalImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> { if (typeof window === "undefined") { return Promise.resolve({ width: null, height: null }); } return new Promise((resolve) => { const objectUrl = URL.createObjectURL(file); const image = new Image(); const finish = (width: number | null, height: number | null) => { URL.revokeObjectURL(objectUrl); resolve({ width, height }); }; image.onload = () => { finish(image.naturalWidth || null, image.naturalHeight || null); }; image.onerror = () => { finish(null, null); }; image.src = objectUrl; }); } 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.mainImageUrls.length > 0 ? candidate.mainImageUrls : 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.mainImageUrls.length > 0 ? candidate.mainImageUrls : 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"; type ReplacementModeType = Extract< AgentGenerationResultModeType, "detail_replacement" | "sku_replacement" >; interface ReplacementResultRecord { url: string | null; text: string; status: Extract; updatedAt: number; } interface ReplacementExecutionContext { modeType: ReplacementModeType; cardIds: string[]; } interface ReplacementTextEditTarget { open: boolean; modeType: ReplacementModeType; cardId: string; imageUrl: string; title: string; } interface ReplacementRerunTarget { open: boolean; modeType: ReplacementModeType; cardId: string; title: string; } type ReplacementResultState = Record< ReplacementModeType, Record >; const MANUAL_PASSTHROUGH_RESULT_TEXT = "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。"; function createEmptyReplacementResultState(): ReplacementResultState { return { detail_replacement: {}, sku_replacement: {}, }; } 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; activeRunIndex?: number | null; }): ReplacementCardStatus { if ( typeof params.activeRunIndex === "number" && params.activeRunIndex >= 0 && params.generatedCount <= params.activeRunIndex ) { if (params.isGenerating) { return params.generatedCount === params.activeRunIndex ? "generating" : "queued"; } if (!params.resultUrl && params.generatedCount > 0) { return "missing"; } } 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-200/70 bg-cyan-500/85 text-white shadow-[0_8px_24px_rgba(6,182,212,0.28)]", }; 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; requestedCardIds?: string[] | null; } 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 ); }).map((item) => { const record = item as Record; return { chatId: String(record.chatId || "").trim(), modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType, expectedCount: Number(record.expectedCount), userTurnCountAtStart: Number(record.userTurnCountAtStart), timestamp: Number(record.timestamp), requestedCardIds: Array.isArray(record.requestedCardIds) ? record.requestedCardIds .map((entry) => String(entry || "").trim()) .filter(Boolean) .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) : null, } satisfies 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; animated?: boolean; }) { const isAnimated = Boolean(params.animated); return (
{isAnimated ? ( <>
) : ( <>
)}
{params.badge} {params.showSpinner ? ( ) : null}
{params.title}
{params.description}
); } export function AgentGenerationWorkspaceDarkV2({ chatId, projectId, selectedAgentPresetId, selectedBrandModel = null, workflowContext = null, workflowBusinessModeSettings = null, workflowFeatureSettings = null, workflowPromptOptimizationEnabled = true, getLatestWorkflowContext, onBack, brandStorePanel, materialSelectionPanel, brandModelPanel, 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 workspaceRootRef = useRef(null); 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; replacementExecutionCardIds: string[] | null; }>({ hiddenPrompt: null, replacementExecutionCardIds: null, }); const [localAgentModeState, setLocalAgentModeState] = useState( () => createEmptyAgentModeWorkspaceState() ); const normalizedIncomingWorkspaceState = useMemo( () => normalizeAgentGenerationProjectPersistedState({ workflowState: {}, workspaceState, resultImageUrls: [], }).workspaceState || createEmptyAgentGenerationProjectWorkspaceState(), [workspaceState] ); const resolvedWorkflowFeatureSettings = useMemo( () => normalizeAgentWorkflowPageFeatureSettings(workflowFeatureSettings), [workflowFeatureSettings] ); const resolvedBusinessModeSettings = useMemo( () => normalizeAgentWorkflowBusinessModeSettings(workflowBusinessModeSettings), [workflowBusinessModeSettings] ); const isBusinessModeStepEnabled = resolvedWorkflowFeatureSettings.presetStepEnabled !== false; const isMaterialStepEnabled = resolvedWorkflowFeatureSettings.materialStepEnabled !== false; const isPsdTemplateEnabled = resolvedWorkflowFeatureSettings.psdTemplateEnabled !== false; const requiresPresetSelection = false; const isDirectModelGenerationMode = !requiresPresetSelection; const hasMaterialStep = isMaterialStepEnabled && Boolean(materialSelectionPanel); useAgentStylePerformanceMonitor({ rootRef: workspaceRootRef, chatId: rawChatId, projectId, }); 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 [activeBusinessModeKey, setActiveBusinessModeKey] = useState("main_sku"); 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 detailReferenceUploadInputRef = useRef(null); const [detailReferenceUploadState, setDetailReferenceUploadState] = useState<{ isUploading: boolean; count: number; }>({ isUploading: false, count: 0, }); 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 [replacementResultState, setReplacementResultState] = useState( () => createEmptyReplacementResultState() ); const [replacementExecutionContext, setReplacementExecutionContext] = useState(null); const [replacementTextEditTarget, setReplacementTextEditTarget] = useState(null); const [replacementRerunTarget, setReplacementRerunTarget] = useState(null); const [replacementRerunPrompt, setReplacementRerunPrompt] = useState(""); 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; }) => { if (!isPsdTemplateEnabled) { toast.info("套 PSD 模板功能已关闭"); return; } 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, isPsdTemplateEnabled] ); 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]); useEffect(() => { if (!isPsdTemplateEnabled && isPsdTemplateDialogOpen) { closePsdTemplateDialog(); } }, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]); const { data: historyData } = useSWR<{ messages: ChatMessage[] }>( rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher, { refreshInterval: selectedAgentPresetId || isDirectModelGenerationMode ? 3000 : 0, revalidateOnFocus: true, } ); const { data: modelsData } = useSWR<{ models: AgentWorkflowModelOption[]; defaultModelId?: string | null; agentWorkflowConfiguredDefaultModelId?: string | null; agentWorkflowDefaultModelId?: string | null; agentWorkflowConfiguredModels?: AgentWorkflowModelOption[]; 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" && !IS_AI_DETAIL_GENERATION_DISABLED ? "ai_detail_generate" : "reference_replace"; const detailReplacementReferenceCards = detailReplacementState.references; const detailReplacementQueryType = detailReplacementState.queryType || "spu"; const detailReplacementProductDetailChannel = detailReplacementState.productDetailChannel || "taobao"; const detailReplacementStrategy = detailReplacementState.replacementStrategy === "product_only" ? "product_only" : DEFAULT_DETAIL_REPLACEMENT_STRATEGY; const skuReplacementState = resolvedAgentModeState.skuReplacement; const skuReplacementReferenceCards = skuReplacementState.references; const presetModeConfig = useMemo( () => normalizeAgentPresetModeConfig(selectedAgentPreset?.defaultParams), [selectedAgentPreset?.defaultParams] ); const businessModeCards = resolvedBusinessModeSettings.modes.length > 0 ? resolvedBusinessModeSettings.modes : defaultAgentWorkflowBusinessModeSettings.modes; const activeBusinessMode = businessModeCards.find((mode) => mode.key === activeBusinessModeKey) || businessModeCards[0] || defaultAgentWorkflowBusinessModeSettings.modes[0]; const activeBusinessModeDefaultModelId = !requiresPresetSelection ? String(activeBusinessMode?.defaultModelId || "").trim() || null : null; const lockedWorkflowModelId = activeBusinessModeDefaultModelId || selectedAgentPreset?.defaultModelId || modelsData?.agentWorkflowConfiguredDefaultModelId || modelsData?.agentWorkflowDefaultModelId || null; const isWorkflowModelLocked = Boolean(lockedWorkflowModelId); const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null; const presetDrivenResultModeType: AgentGenerationResultModeType = selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement" ? detailReplacementMethod === "ai_detail_generate" ? "detail_generation" : "detail_replacement" : selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "sku_replacement" ? "sku_replacement" : "scene_generation"; const businessModeResultModeType = getResultModeTypeByBusinessMode( activeBusinessMode, detailReplacementMethod ); const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection ? presetDrivenResultModeType : businessModeResultModeType; const isSceneGenerationPreset = currentResultModeType === "scene_generation"; const isDetailReplacementPreset = currentResultModeType === "detail_replacement" || currentResultModeType === "detail_generation"; const isSkuReplacementPreset = currentResultModeType === "sku_replacement"; const isDetailAiGenerationMethod = currentResultModeType === "detail_generation"; const isReplacementPreset = currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"; const isProductOnlyDetailReplacementMode = currentResultModeType === "detail_replacement" && detailReplacementStrategy === "product_only"; const isSelectedBusinessModeUnavailable = activeBusinessMode?.enabled === false; const productSlotCount = selectedAgentPreset?.type === "image" && requiresPresetSelection ? isSceneGenerationPreset ? presetModeConfig.scene.productImageSlotCount : isDetailReplacementPreset ? presetModeConfig.detail.productImageSlotCount : isSkuReplacementPreset ? presetModeConfig.sku.productImageSlotCount : 2 : 2; const modelSlotEnabled = selectedAgentPreset?.type === "image" && requiresPresetSelection ? isSceneGenerationPreset ? presetModeConfig.scene.includeModelSlot : isDetailReplacementPreset ? presetModeConfig.detail.includeModelSlot : isSkuReplacementPreset ? presetModeConfig.sku.includeModelSlot : true : isSceneGenerationPreset || isDetailReplacementPreset || isSkuReplacementPreset; const modelSlotIndex = modelSlotEnabled ? productSlotCount : -1; const hasAgentUploadSlots = Boolean( hasMaterialStep && 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 isDirectReferenceUploadEnabled = isDirectModelGenerationMode && !hasMaterialStep; const directReferenceUploadSlots = useMemo( () => Array.from({ length: DIRECT_MODEL_UPLOAD_SLOT_COUNT }, (_, index) => ({ slotIndex: index, label: `参考图${index + 1}`, })), [] ); const visibleAttachmentUploadSlots = isDirectReferenceUploadEnabled ? directReferenceUploadSlots : agentUploadSlots; const hasVisibleAttachmentUploadSlots = visibleAttachmentUploadSlots.length > 0; const requiredProductUploadCount = hasAgentUploadSlots ? productSlotCount : 0; const detailReferenceTarget = presetModeConfig.detail.referenceImageSource; const skuReferenceTarget = presetModeConfig.sku.referenceImageSource; const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget); const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget); const getCurrentWorkflowBrandName = useCallback( () => String((getLatestWorkflowContext?.() ?? workflowContext)?.brand || "").trim(), [getLatestWorkflowContext, workflowContext] ); const workflowBrandName = getCurrentWorkflowBrandName(); const shouldAllowModelOverride = !isWorkflowModelLocked && (isDirectModelGenerationMode || !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 detailReplacementProductSourceImages = useMemo(() => { const primaryImages = referenceImages.filter((image) => image.source === "primary"); const fallbackProductImages = referenceImages.filter( (image) => image.source === "primary" || image.source === "secondary" ); const productImages = primaryImages.length > 0 ? primaryImages : fallbackProductImages; return productImages .filter((image) => isValidChatHttpUrl(image.url)) .slice(0, 16) .map((image) => ({ url: image.url, label: image.label || image.name || "产品图", name: image.name || image.label || "产品图", assetId: image.assetId || null, })); }, [referenceImages]); const detailReplacementModelSourceImage = useMemo(() => { const referenceModelImage = referenceImages.find( (image) => image.source === "brandModel" && isValidChatHttpUrl(image.url) ); if (referenceModelImage) { return { url: referenceModelImage.url, label: referenceModelImage.label || referenceModelImage.name || "模特图", name: referenceModelImage.name || referenceModelImage.label || "模特图", assetId: referenceModelImage.assetId || null, }; } if (selectedModelAttachment && isValidChatHttpUrl(selectedModelAttachment.url)) { return { url: selectedModelAttachment.url, label: selectedModelAttachment.name || "模特图", name: selectedModelAttachment.name || "模特图", assetId: selectedModelAttachment.assetId || null, }; } if (selectedBrandModel?.imageUrl && isValidChatHttpUrl(selectedBrandModel.imageUrl)) { return { url: selectedBrandModel.imageUrl, label: selectedBrandModel.displayName || `${selectedBrandModel.brandName || "品牌"}模特图`, name: selectedBrandModel.displayName || `${selectedBrandModel.brandName || "品牌"}模特图`, assetId: selectedBrandModel.assetId || null, }; } return null; }, [ referenceImages, selectedBrandModel?.assetId, selectedBrandModel?.brandName, selectedBrandModel?.displayName, selectedBrandModel?.imageUrl, selectedModelAttachment, ]); 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 (isDirectModelGenerationMode && hasMaterialStep) { const seenKeys = new Set(); const pushUnique = (attachment: PickedAttachment | null) => { if (!attachment) return null; const key = getAttachmentIdentityKey(attachment); if (key && seenKeys.has(key)) { return null; } if (key) { seenKeys.add(key); } return attachment; }; const workflowAttachments = referenceImages .map((image) => toWorkflowReferenceAttachment({ url: image.url, name: image.name, label: image.label, mimeType: image.mimeType, assetId: image.assetId || null, }) ) .map(pushUnique) .filter((attachment): attachment is PickedAttachment => Boolean(attachment)); const replacementAttachments = currentResultModeType === "detail_replacement" ? detailReplacementReferenceCards.map((card, index) => toWorkflowReferenceAttachment({ url: card.imageUrl, name: `详情参考图 ${index + 1}`, label: card.label, mimeType: "image/png", assetId: null, }) ) : currentResultModeType === "sku_replacement" ? skuReplacementReferenceCards.map((card, index) => toWorkflowReferenceAttachment({ url: card.imageUrl, name: `SKU参考图 ${index + 1}`, label: card.label, mimeType: "image/png", assetId: null, }) ) : []; return [ ...workflowAttachments, ...replacementAttachments .map(pushUnique) .filter((attachment): attachment is PickedAttachment => Boolean(attachment)), ]; } if (isDirectReferenceUploadEnabled) { return attachments .slice(0, DIRECT_MODEL_UPLOAD_SLOT_COUNT) .map((attachment) => sanitizeChatAttachment(attachment)) .filter((attachment): attachment is PickedAttachment => Boolean(attachment)); } 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, hasMaterialStep, currentResultModeType, detailReplacementReferenceCards, isDirectModelGenerationMode, isDirectReferenceUploadEnabled, isSceneGenerationPreset, referenceImages, scenePrimaryReferenceCount, sceneSecondaryReferenceAttachments, selectedModelAttachment, selectedProductAttachments, skuReplacementReferenceCards, ]); const filledProductAttachmentCount = selectedProductAttachments.length; const missingAttachmentCount = Math.max( requiredProductUploadCount - filledProductAttachmentCount, 0 ); const directReferenceAttachmentCount = attachments .slice(0, DIRECT_MODEL_UPLOAD_SLOT_COUNT) .filter((item) => Boolean(item?.url)).length; const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`; const directReferenceAttachmentSummary = `已上传 ${directReferenceAttachmentCount}/${DIRECT_MODEL_UPLOAD_SLOT_COUNT} 张参考图`; 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 detailGenerationMode = normalizeAgentDetailGenerationMode( detailReplacementState.generationMode ); const detailGenerationBrief = sanitizeAgentDetailBrief(detailReplacementState.brief); const replacementTextSuggestionContext = useMemo(() => { const latestWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; return { audience: detailGenerationBrief.targetAudience || null, usageScenario: detailGenerationBrief.usageScenario || null, coreNeed: detailGenerationBrief.coreNeed || null, productName: detailGenerationBrief.productName || null, workflowSummary: latestWorkflowContext?.summaryText || null, workflowPrompt: latestWorkflowContext?.promptText || null, userPrompt: input.trim() || null, presetName: selectedAgentPreset?.name || null, }; }, [ detailGenerationBrief.coreNeed, detailGenerationBrief.productName, detailGenerationBrief.targetAudience, detailGenerationBrief.usageScenario, getLatestWorkflowContext, input, selectedAgentPreset?.name, workflowContext, ]); 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 skuReplacementQueryInput = skuReplacementState.query; const skuReplacementQueryType = skuReplacementState.queryType; const skuReplacementCandidates = skuReplacementState.candidates; const skuReplacementSelectedCandidateId = skuReplacementState.selectedCandidateId; const transportStateRef = useRef<{ selectedAgentPresetId: string | null; selectedAgentPresetType: AgentPreset["type"] | null; selectedModelId: string; lockedWorkflowModelId: string | null; imageParams: ImageGenerationParams; isDetailReplacementPreset: boolean; isSkuReplacementPreset: boolean; detailReplacementQueryInput: string; detailReplacementQueryType: AgentWorkflowReferenceQueryType; detailReplacementStrategy: AgentDetailReplacementStrategy; detailReplacementSelectedCandidateId: string | null; detailReplacementReferenceCards: typeof detailReplacementReferenceCards; detailReplacementProductSourceImages: typeof detailReplacementProductSourceImages; detailReplacementModelSourceImage: typeof detailReplacementModelSourceImage; skuReplacementQueryInput: string; skuReplacementQueryType: typeof skuReplacementQueryType; skuReplacementSelectedCandidateId: string | null; skuReplacementReferenceCards: typeof skuReplacementReferenceCards; selectedProductAttachmentCount: number; }>({ selectedAgentPresetId: null, selectedAgentPresetType: null, selectedModelId: "auto", lockedWorkflowModelId: null, imageParams: { aspectRatio: "auto", imageSize: "2K", imageCount: 1, }, isDetailReplacementPreset: false, isSkuReplacementPreset: false, detailReplacementQueryInput: "", detailReplacementQueryType: "spu", detailReplacementStrategy: DEFAULT_DETAIL_REPLACEMENT_STRATEGY, detailReplacementSelectedCandidateId: null, detailReplacementReferenceCards: [], detailReplacementProductSourceImages: [], detailReplacementModelSourceImage: null, skuReplacementQueryInput: "", skuReplacementQueryType: "sku", skuReplacementSelectedCandidateId: null, skuReplacementReferenceCards: [], selectedProductAttachmentCount: 0, }); transportStateRef.current = { selectedAgentPresetId: selectedAgentPresetId || null, selectedAgentPresetType: selectedAgentPreset?.type || null, selectedModelId, lockedWorkflowModelId, imageParams, isDetailReplacementPreset, isSkuReplacementPreset, detailReplacementQueryInput, detailReplacementQueryType, detailReplacementStrategy, detailReplacementSelectedCandidateId: detailReplacementSelectedCandidateId || null, detailReplacementReferenceCards, detailReplacementProductSourceImages, detailReplacementModelSourceImage, skuReplacementQueryInput, skuReplacementQueryType, skuReplacementSelectedCandidateId: skuReplacementSelectedCandidateId || null, skuReplacementReferenceCards, selectedProductAttachmentCount: currentResultModeType === "detail_replacement" ? detailReplacementProductSourceImages.length : 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 configuredWorkflowModels = useMemo( () => modelsData?.agentWorkflowConfiguredModels || [], [modelsData?.agentWorkflowConfiguredModels] ); const executableModelOptions = useMemo(() => { const seen = new Set(); return [...selectableModels, ...configuredWorkflowModels].filter((model) => { if (seen.has(model.id)) return false; seen.add(model.id); return true; }); }, [configuredWorkflowModels, selectableModels]); const executableModelIdSet = useMemo( () => new Set(executableModelOptions.map((model) => model.id)), [executableModelOptions] ); const lockedWorkflowModel = useMemo( () => lockedWorkflowModelId ? executableModelOptions.find((model) => model.id === lockedWorkflowModelId) || null : null, [executableModelOptions, lockedWorkflowModelId] ); const currentModel = useMemo( () => lockedWorkflowModel || executableModelOptions.find((model) => model.id === selectedModelId) || selectableModels[0], [executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId] ); const lockedWorkflowModelLabel = lockedWorkflowModel?.displayName || selectedAgentPreset?.defaultModel?.displayName || (lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置"); const [activeToolStep, setActiveToolStep] = useState("settings"); const hasBrandModelStep = Boolean(brandModelPanel) && modelSlotEnabled; const hasBrandStoreStep = Boolean(brandStorePanel); const hasWorkflowStyleStep = Boolean(workflowStylePanel) || hasBrandModelStep; const shouldShowDirectUploadInStyleStep = isDirectReferenceUploadEnabled && hasWorkflowStyleStep; const visibleWorkflowSectionKeys = useMemo( () => [ ...(hasBrandStoreStep ? ["brand"] : []), ...(hasWorkflowStyleStep ? ["style"] : []), ...(isBusinessModeStepEnabled ? ["mode"] : []), ...(hasMaterialStep ? ["materials"] : []), "generate", ], [hasBrandStoreStep, hasMaterialStep, hasWorkflowStyleStep, isBusinessModeStepEnabled] ); const getWorkflowSectionBadge = useCallback( (key: "brand" | "mode" | "materials" | "style" | "generate") => { const index = visibleWorkflowSectionKeys.indexOf(key); return String(index >= 0 ? index + 1 : visibleWorkflowSectionKeys.length + 1).padStart( 2, "0" ); }, [visibleWorkflowSectionKeys] ); const firstWorkflowSectionAnchor = hasBrandStoreStep ? "agent-anchor-brand" : hasWorkflowStyleStep ? "agent-anchor-style" : isBusinessModeStepEnabled ? "agent-anchor-mode" : hasMaterialStep ? "agent-anchor-materials" : "agent-anchor-generate"; 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 requestedReplacementCardIdSet = new Set( (generationRequestSnapshotRef.current.replacementExecutionCardIds || []) .map((item) => String(item || "").trim()) .filter(Boolean) ); const resolvedAgentPresetId = requiresPresetSelection ? transportState.selectedAgentPresetId || selectedAgentPresetId || undefined : undefined; const resolvedSelectedModelId = transportState.lockedWorkflowModelId || (transportState.selectedModelId !== "auto" ? transportState.selectedModelId : effectiveDefaultModelId || transportState.selectedModelId); const replacementModePayload = 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 ? transportState.detailReplacementQueryType : transportState.skuReplacementQueryType, replacementStrategy: transportState.isDetailReplacementPreset ? transportState.detailReplacementStrategy : undefined, 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) => requestedReplacementCardIdSet.size === 0 || requestedReplacementCardIdSet.has(String(item.id || "").trim()) ) .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, passthrough: item.passthrough === true, assignedProductIndex: transportState.selectedProductAttachmentCount > 0 ? Math.min( item.assignedProductIndex ?? 0, Math.max(transportState.selectedProductAttachmentCount - 1, 0) ) : null, })), sourceImages: transportState.isDetailReplacementPreset ? { productImages: transportState.detailReplacementProductSourceImages, modelImage: transportState.detailReplacementModelSourceImage, } : undefined, } : 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: requiresPresetSelection && isValidChatUuid(resolvedAgentPresetId) ? resolvedAgentPresetId : undefined, selectedChatModel: String(resolvedSelectedModelId || "auto").trim() || "auto", composerMode: isDirectModelGenerationMode || 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 (isWorkflowModelLocked) { setSelectedModelId((current) => (current === "auto" ? current : "auto")); return; } if (normalizedIncomingWorkspaceState.selectedModelId) { const nextModelId = normalizedIncomingWorkspaceState.selectedModelId; setSelectedModelId((current) => current === nextModelId ? current : nextModelId ); return; } if (effectiveDefaultModelId) { const nextModelId = effectiveDefaultModelId; setSelectedModelId((current) => current === nextModelId ? current : nextModelId ); } }, [ effectiveDefaultModelId, isWorkflowModelLocked, normalizedIncomingWorkspaceState.selectedModelId, ]); // 初始化附件 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 rawText = textParts.join("\n\n"); const imageUrls = Array.from( new Set(textParts.flatMap((text) => extractImageUrlsFromText(text))) ); return { messageId: message.id, imageUrls, text: normalizeAssistantText(rawText), replacementCardId: extractReplacementCardIdFromAssistantText(rawText), }; }) .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 || "", replacementCardId: group.replacementCardId || null, })), [replacementGeneratedGroupsAfterLastUser] ); const currentReplacementExecutionCardIds = useMemo(() => { if (currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") { return [] as string[]; } if ( replacementExecutionContext?.modeType === currentResultModeType && replacementExecutionContext.cardIds.length > 0 ) { return replacementExecutionContext.cardIds; } if ( pendingGeneration?.modeType === currentResultModeType && Array.isArray(pendingGeneration.requestedCardIds) && pendingGeneration.requestedCardIds.length > 0 ) { return pendingGeneration.requestedCardIds; } const fallbackCards = currentResultModeType === "detail_replacement" ? detailReplacementReferenceCards : skuReplacementReferenceCards; return fallbackCards.map((card) => card.id); }, [ currentResultModeType, detailReplacementReferenceCards, pendingGeneration?.modeType, pendingGeneration?.requestedCardIds, replacementExecutionContext, skuReplacementReferenceCards, ]); useEffect(() => { setReplacementResultState((current) => { const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id)); const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id)); let changed = false; const nextDetail = Object.fromEntries( Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId)) ); if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) { changed = true; } const nextSku = Object.fromEntries( Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId)) ); if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) { changed = true; } if (!changed) { return current; } return { detail_replacement: nextDetail, sku_replacement: nextSku, }; }); }, [detailReplacementReferenceCards, skuReplacementReferenceCards]); useEffect(() => { if ( (currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") || latestReplacementResults.length === 0 ) { return; } const cardIds = currentReplacementExecutionCardIds; if (cardIds.length === 0) { return; } setReplacementResultState((current) => { const nextModeState = { ...current[currentResultModeType] }; let changed = false; latestReplacementResults.forEach((result, index) => { const cardId = result.replacementCardId || cardIds[index]; if (!cardId) { return; } const inferredStatus = inferReplacementCardStatusFromText(result.text); const nextRecord: ReplacementResultRecord = { url: result.url || null, text: result.text || "", status: inferredStatus === "passthrough" || inferredStatus === "fallback" ? inferredStatus : "completed", updatedAt: Date.now(), }; const previousRecord = nextModeState[cardId]; if ( previousRecord?.url === nextRecord.url && previousRecord?.text === nextRecord.text && previousRecord?.status === nextRecord.status ) { return; } nextModeState[cardId] = nextRecord; changed = true; }); if (!changed) { return current; } return { ...current, [currentResultModeType]: nextModeState, }; }); }, [currentReplacementExecutionCardIds, currentResultModeType, latestReplacementResults]); const detailReplacementResultUrls = useMemo( () => detailReplacementReferenceCards .map((card) => { const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null; const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null; const nextUrl = recordUrl || fallbackUrl; return nextUrl && !normalizedDeletedResultCollections.detail_replacement.includes(nextUrl) ? nextUrl : null; }) .filter((url): url is string => Boolean(url)), [ detailReplacementReferenceCards, normalizedDeletedResultCollections.detail_replacement, replacementResultState.detail_replacement, ] ); const skuReplacementResultUrls = useMemo( () => skuReplacementReferenceCards .map((card) => { const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null; return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl) ? nextUrl : null; }) .filter((url): url is string => Boolean(url)), [ normalizedDeletedResultCollections.sku_replacement, replacementResultState.sku_replacement, skuReplacementReferenceCards, ] ); 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 : currentResultModeType === "detail_replacement" ? detailReplacementResultUrls : currentResultModeType === "sku_replacement" ? skuReplacementResultUrls : 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: Array.from( new Set([ ...detailReplacementResultUrls, ...excludeDeletedResultUrls( resultCollections?.detail_replacement, normalizedDeletedResultCollections.detail_replacement ), ]) ), sku_replacement: Array.from( new Set([ ...skuReplacementResultUrls, ...excludeDeletedResultUrls( resultCollections?.sku_replacement, normalizedDeletedResultCollections.sku_replacement ), ]) ), }; return { ...baseCollections, [currentResultModeType]: Array.from( new Set([ ...baseCollections[currentResultModeType], ...excludeDeletedResultUrls( currentLiveResultUrls, normalizedDeletedResultCollections[currentResultModeType] ), ]) ), } as Record; }, [ currentLiveResultUrls, currentResultModeType, detailReplacementResultUrls, normalizedDeletedResultCollections, resultCollections, skuReplacementResultUrls, ] ); 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 = replacementResultState.detail_replacement[card.id] || null; const localPassthroughResultUrl = !resultMeta?.url && card.passthrough && card.imageUrl ? card.imageUrl : null; const resultUrl = resultMeta?.url && !normalizedDeletedResultCollections.detail_replacement.includes(resultMeta.url) ? resultMeta.url : localPassthroughResultUrl && !normalizedDeletedResultCollections.detail_replacement.includes( localPassthroughResultUrl ) ? localPassthroughResultUrl : null; const resultText = resultMeta?.url ? resultMeta?.text || "" : localPassthroughResultUrl ? MANUAL_PASSTHROUGH_RESULT_TEXT : ""; const activeRunIndex = pendingGeneration?.modeType === "detail_replacement" && Array.isArray(pendingGeneration.requestedCardIds) ? pendingGeneration.requestedCardIds.indexOf(card.id) : -1; return { ...card, index, resultUrl, resultText, queuePosition: activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement" ? activeRunIndex + 1 : null, queueTotal: pendingGeneration?.modeType === "detail_replacement" && Array.isArray(pendingGeneration.requestedCardIds) ? pendingGeneration.requestedCardIds.length : null, status: getReplacementCardStatus({ index, resultUrl, resultText, generatedCount: pendingGeneration?.modeType === "detail_replacement" ? latestReplacementResults.length : 0, isGenerating: isDetailResultModeGenerating, activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, }), }; }), [ detailReplacementReferenceCards, isDetailResultModeGenerating, latestReplacementResults, normalizedDeletedResultCollections.detail_replacement, pendingGeneration?.modeType, pendingGeneration?.requestedCardIds, replacementResultState.detail_replacement, ] ); const skuResultCards = useMemo( () => skuReplacementReferenceCards.map((card, index) => { const resultMeta = replacementResultState.sku_replacement[card.id] || null; const localPassthroughResultUrl = !resultMeta?.url && card.passthrough && card.imageUrl ? card.imageUrl : null; const resultUrl = resultMeta?.url && !normalizedDeletedResultCollections.sku_replacement.includes(resultMeta.url) ? resultMeta.url : localPassthroughResultUrl && !normalizedDeletedResultCollections.sku_replacement.includes( localPassthroughResultUrl ) ? localPassthroughResultUrl : null; const resultText = resultMeta?.url ? resultMeta?.text || "" : localPassthroughResultUrl ? MANUAL_PASSTHROUGH_RESULT_TEXT : ""; const activeRunIndex = pendingGeneration?.modeType === "sku_replacement" && Array.isArray(pendingGeneration.requestedCardIds) ? pendingGeneration.requestedCardIds.indexOf(card.id) : -1; return { ...card, index, resultUrl, resultText, status: getReplacementCardStatus({ index, resultUrl, resultText, generatedCount: pendingGeneration?.modeType === "sku_replacement" ? latestReplacementResults.length : 0, isGenerating: isSkuResultModeGenerating, activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, }), }; }), [ isSkuResultModeGenerating, latestReplacementResults, normalizedDeletedResultCollections.sku_replacement, pendingGeneration?.modeType, pendingGeneration?.requestedCardIds, replacementResultState.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 detailReplacementBatchDownloadUrls = useMemo( () => detailResultCards .map((card) => card.resultUrl) .filter((url): url is string => Boolean(url)), [detailResultCards] ); const skuReplacementBatchDownloadUrls = useMemo( () => skuResultCards .map((card) => card.resultUrl) .filter((url): url is string => Boolean(url)), [skuResultCards] ); const detailReplacementOrderedDownloadUrls = detailReplacementBatchDownloadUrls.length > 0 ? detailReplacementBatchDownloadUrls : mergedResultCollections.detail_replacement; const skuReplacementOrderedDownloadUrls = skuReplacementBatchDownloadUrls.length > 0 ? skuReplacementBatchDownloadUrls : mergedResultCollections.sku_replacement; const detailRemainingCount = useMemo( () => detailResultCards.filter( (card) => !["completed", "passthrough", "fallback"].includes(card.status) ).length, [detailResultCards] ); const skuRemainingCount = 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 brandStepBadge = getWorkflowSectionBadge("brand"); const modeStepBadge = getWorkflowSectionBadge("mode"); const materialStepBadge = getWorkflowSectionBadge("materials"); const styleStepBadge = getWorkflowSectionBadge("style"); const generateStepBadge = getWorkflowSectionBadge("generate"); const toolSteps = useMemo( () => [ ...(hasBrandModelStep ? [ { key: "brand-model" as const, label: "品牌模特", description: "根据品牌选择模特图", icon: UserRound, disabled: false, completed: hasBrandModelSelection, optional: true, }, ] : []), { key: "settings" as const, label: mergedSettingsStepLabel, description: mergedSettingsStepDescription, icon: Settings2, disabled: false, completed: Boolean(input.trim()) || hasMaterialSelections || hasSatisfiedSlotRequirement, optional: false, }, ], [ hasBrandModelSelection, hasBrandModelStep, hasMaterialSelections, hasSatisfiedSlotRequirement, mergedSettingsStepDescription, mergedSettingsStepLabel, hasMaterialStep, input, ] ); 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 = isWorkflowModelLocked ? "auto" : 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; }); }, [isWorkflowModelLocked, 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 (isDirectReferenceUploadEnabled) { setIsAttachmentSlotExpanded(true); return; } if (!selectedAgentPresetId || !hasAgentUploadSlots) { setIsAttachmentSlotExpanded(false); } }, [hasAgentUploadSlots, isDirectReferenceUploadEnabled, selectedAgentPresetId]); useEffect(() => { if (requiresPresetSelection && !selectedAgentPresetId) { setActiveToolStep("preset"); return; } setActiveToolStep((current) => { if ( current !== "preset" && current !== "materials" && current !== "prompt" ) { return current; } if (hasBrandModelStep) return "brand-model"; return "settings"; }); }, [ hasBrandModelStep, requiresPresetSelection, selectedAgentPresetId, ]); useEffect(() => { if (!toolSteps.some((step) => step.key === activeToolStep)) { setActiveToolStep(toolSteps[0]?.key || "settings"); } }, [activeToolStep, 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(visibleAttachmentUploadSlots.length, 1); setAttachments((current) => { const slots: Array = Array.from( { length: slotCount }, (_, index) => current[index] ?? null ); slots[slotIndex] = attachment; return slots; }); }, [visibleAttachmentUploadSlots.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(`${visibleAttachmentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`); } catch (error) { toast.error(error instanceof Error ? error.message : "上传失败"); } finally { setSlotUploading(null); event.currentTarget.value = ""; } }, [updateAttachmentAtSlot, visibleAttachmentUploadSlots] ); const handleBatchDownloadImages = useCallback(async (params: { imageUrls: string[]; zipName: string; scope: "scene" | "detail" | "sku"; }) => { const normalizedUrls = (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 batchFileBaseName = params.scope === "sku" ? "sku-result" : params.scope === "scene" ? "scene-result" : "detail-result"; const form = document.createElement("form"); form.method = "POST"; form.action = "/api/canvas/download-zip"; form.style.display = "none"; const payloadInput = document.createElement("input"); payloadInput.type = "hidden"; payloadInput.name = "payload"; payloadInput.value = JSON.stringify({ folderName: params.zipName, files: normalizedUrls.map((imageUrl) => ({ url: imageUrl, name: `${params.zipName}-${batchFileBaseName}`, })), }); form.appendChild(payloadInput); document.body.appendChild(form); form.submit(); form.remove(); toast.success("已开始服务端打包下载,请留意浏览器下载栏"); } catch (error) { toast.error(error instanceof Error ? error.message : "批量下载失败"); } finally { window.setTimeout(() => { setBatchDownloadState(null); }, 1200); } }, []); 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 handleUpdateReplacementResultRecord = useCallback((params: { modeType: ReplacementModeType; cardId: string; url: string | null; text?: string | null; status?: Extract; }) => { setReplacementResultState((current) => { const currentModeState = current[params.modeType]; const previousRecord = currentModeState[params.cardId]; const inferredStatus = inferReplacementCardStatusFromText(params.text); const nextRecord: ReplacementResultRecord = { url: params.url, text: String(params.text || "").trim(), status: params.status || (inferredStatus === "passthrough" || inferredStatus === "fallback" ? inferredStatus : null) || previousRecord?.status || "completed", updatedAt: Date.now(), }; if ( previousRecord?.url === nextRecord.url && previousRecord?.text === nextRecord.text && previousRecord?.status === nextRecord.status ) { return current; } return { ...current, [params.modeType]: { ...currentModeState, [params.cardId]: nextRecord, }, }; }); }, []); const handleOpenReplacementTextEdit = useCallback((params: { modeType: ReplacementModeType; cardId: string; imageUrl: string; title: string; }) => { const rawUrl = String(params.imageUrl || "").trim(); if (!rawUrl) { toast.error("结果图地址无效"); return; } setReplacementTextEditTarget({ open: true, modeType: params.modeType, cardId: params.cardId, imageUrl: rawUrl, title: String(params.title || "").trim() || "待改字结果", }); }, []); const getReplacementExecutionCards = useCallback( (params: { modeType: ReplacementModeType; requestedCardIds?: string[] | null; }) => { const requestedCardIds = (params.requestedCardIds || []) .map((item) => String(item || "").trim()) .filter(Boolean); const references = params.modeType === "detail_replacement" ? detailReplacementReferenceCards : skuReplacementReferenceCards; const resultCards = params.modeType === "detail_replacement" ? detailResultCards : skuResultCards; if (references.length === 0) { return { targetCards: [] as typeof references, isResume: false, }; } if (requestedCardIds.length > 0) { const requestedCardIdSet = new Set(requestedCardIds); return { targetCards: references.filter((card) => requestedCardIdSet.has(card.id)), isResume: false, }; } const processedStatuses = new Set([ "completed", "passthrough", "fallback", ]); const remainingIds = resultCards .filter((card) => !processedStatuses.has(card.status)) .map((card) => card.id); const processedCount = resultCards.length - remainingIds.length; const shouldResume = processedCount > 0 && remainingIds.length > 0 && remainingIds.length < references.length; return { targetCards: shouldResume ? references.filter((card) => remainingIds.includes(card.id)) : references, isResume: shouldResume, }; }, [ detailReplacementReferenceCards, detailResultCards, skuReplacementReferenceCards, skuResultCards, ] ); const handleSend = useCallback((options?: { replacementModeType?: ReplacementModeType; replacementCardIds?: string[] | null; promptText?: string | null; }) => { if (isSelectedBusinessModeUnavailable || activeBusinessModeKey === "buyer_show") { toast.error("当前生图模式暂未开放"); return; } if (isDetailReplacementPreset && isDetailAiGenerationMethod) { void handleRunAiDetailGeneration(); return; } if (requiresPresetSelection && !selectedAgentPresetId) { toast.error("请先选择生图模式"); return; } if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) { toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`); return; } if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); return; } const replacementModeType = options?.replacementModeType || (isDetailReplacementPreset ? "detail_replacement" : isSkuReplacementPreset ? "sku_replacement" : null); const requestedReplacementCardIds = (options?.replacementCardIds || []) .map((item) => String(item || "").trim()) .filter(Boolean); const replacementExecutionPlan = replacementModeType ? getReplacementExecutionCards({ modeType: replacementModeType, requestedCardIds: requestedReplacementCardIds, }) : null; const replacementExecutionCards = replacementExecutionPlan?.targetCards || []; if (replacementModeType && replacementExecutionCards.length === 0) { if (requestedReplacementCardIds.length > 0) { toast.error("当前结果卡片不存在或已被移除"); return; } toast.info("当前结果都已完成,如需调整请使用单张“重新生图”"); return; } const uploadedProductAttachmentCount = selectedProductAttachments.length; if (hasAgentUploadSlots && uploadedProductAttachmentCount < requiredProductUploadCount) { toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张产品图`); return; } const resolvedModelId = lockedWorkflowModelId || (selectedModelId !== "auto" ? selectedModelId : effectiveDefaultModelId || selectedModelId); if (!resolvedModelId) { toast.error("当前风格化未配置模型"); return; } if ( resolvedModelId !== "auto" && !executableModelIdSet.has(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 = String(options?.promptText || "").trim() || input.trim(); if (!textContent && replacementModeType && replacementExecutionCards.length === 1) { textContent = "请重新生成当前这张替换结果,保持版式和主体一致。"; } if ( !textContent && replacementModeType && replacementExecutionPlan?.isResume && replacementExecutionCards.length > 0 ) { textContent = "请继续处理当前剩余的替换结果。"; } 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, replacementExecutionCardIds: replacementModeType && replacementExecutionCards.length > 0 ? replacementExecutionCards.map((card) => card.id) : null, }; setReplacementExecutionContext( replacementModeType && replacementExecutionCards.length > 0 ? { modeType: replacementModeType, cardIds: replacementExecutionCards.map((card) => card.id), } : null ); setActiveResultModeType(currentResultModeType); setSelectedResultUrl(null); setPendingGeneration({ chatId, modeType: currentResultModeType, expectedCount: currentResultModeType === "scene_generation" ? Math.max(1, imageParams.imageCount || 1) : replacementModeType && replacementExecutionCards.length > 0 ? replacementExecutionCards.length : currentResultModeType === "detail_replacement" ? detailReplacementReferenceCards.length : skuReplacementReferenceCards.length, userTurnCountAtStart: userMessageCount, timestamp: Date.now(), requestedCardIds: replacementModeType && replacementExecutionCards.length > 0 ? replacementExecutionCards.map((card) => card.id) : null, }); if (replacementExecutionPlan?.isResume && replacementExecutionCards.length > 0) { toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`); } try { sendMessage({ role: "user", parts }); if (selectedAgentPreset?.type !== "image") { setInput(""); } } catch (error) { generationRequestSnapshotRef.current = { hiddenPrompt: null, replacementExecutionCardIds: null, }; setPendingGeneration(null); toast.error(error instanceof Error ? error.message : "发送生成请求失败"); } }, [ attachments, activeBusinessModeKey, detailResultCards, detailReplacementReferenceCards.length, handleRunAiDetailGeneration, currentResultModeType, getReplacementExecutionCards, hasAgentUploadSlots, imageParams.imageCount, input, isDetailAiGenerationMethod, isDetailReplacementPreset, isSelectedBusinessModeUnavailable, isSkuReplacementPreset, effectiveDefaultModelId, executableModelIdSet, lockedWorkflowModelId, requiredProductUploadCount, requiresPresetSelection, selectedAgentPreset, selectedAgentPresetId, selectedModelId, sendMessage, setPendingGeneration, skuReplacementReferenceCards.length, skuResultCards, userMessageCount, getLatestWorkflowContext, isSceneGenerationPreset, workflowContext, sceneMessageAttachments, scenePrimaryReferenceCount, sceneSecondaryReferenceCount, selectedModelAttachment, ]); const handleStopGeneration = useCallback(() => { detailGenerationCancelRef.current = true; stop(); generationRequestSnapshotRef.current = { hiddenPrompt: hiddenPromptRef.current, replacementExecutionCardIds: null, }; setPendingGeneration(null); setMessages((current) => current); toast.info("已停止当前生成任务"); }, [setMessages, stop]); const handleApplyReplacementTextEditResult = useCallback((result: { originalUrl: string; thumbnailUrl?: string | null; model?: string | null; }) => { if (!replacementTextEditTarget) { return; } handleUpdateReplacementResultRecord({ modeType: replacementTextEditTarget.modeType, cardId: replacementTextEditTarget.cardId, url: result.originalUrl, text: "已在当前页面完成文字编辑。", status: "completed", }); setReplacementTextEditTarget((current) => current ? { ...current, imageUrl: result.originalUrl, } : current ); }, [handleUpdateReplacementResultRecord, replacementTextEditTarget]); const handleConfirmReplacementRerun = useCallback(() => { if (!replacementRerunTarget) { return; } handleSend({ replacementModeType: replacementRerunTarget.modeType, replacementCardIds: [replacementRerunTarget.cardId], promptText: replacementRerunPrompt, }); setReplacementRerunTarget(null); setReplacementRerunPrompt(""); }, [handleSend, replacementRerunPrompt, replacementRerunTarget]); 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 updateDetailReplacementStrategy = useCallback( (replacementStrategy: AgentDetailReplacementStrategy) => { updateDetailReplacementState((current) => ({ ...current, replacementStrategy, })); }, [updateDetailReplacementState] ); const handleAiDetailGenerationUnavailableClick = useCallback(() => { toast.info(AI_DETAIL_GENERATION_DISABLED_MESSAGE); }, []); useEffect(() => { if (!IS_AI_DETAIL_GENERATION_DISABLED) { return; } if (detailReplacementState.method !== "ai_detail_generate") { return; } updateDetailReplacementState((current) => ({ ...current, method: "reference_replace", })); }, [detailReplacementState.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 (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 latestWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; const workflowHint = latestWorkflowContext?.promptText ? `\n\n【工作流风格要求】\n${latestWorkflowContext.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 = lockedWorkflowModelId || (selectedModelId !== "auto" ? selectedModelId : effectiveDefaultModelId || undefined); if ( resolvedModelId && resolvedModelId !== "auto" && !executableModelIdSet.has(resolvedModelId) ) { toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置"); return; } 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(), }); let completedDetailGenerationCount = 0; 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: Array = Array.from( { length: prompts.length }, () => null ); const publishGeneratedImages = () => { const compactImages = compactDirectDetailGenerationImages(generatedImages); completedDetailGenerationCount = compactImages.length; setDetailGenerationRunResults(compactImages); }; const generateDetailScreen = async (index: number) => { 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[index] = nextImage; publishGeneratedImages(); }; await generateDetailScreen(0); const remainingPromptIndexes = prompts.slice(1).map((_, index) => index + 1); if (remainingPromptIndexes.length > 0) { await runAgentBoundedParallel( remainingPromptIndexes, AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT, async (promptIndex) => { await generateDetailScreen(promptIndex); } ); } const finalGeneratedImages = compactDirectDetailGenerationImages(generatedImages); updateDetailGenerationProgressState({ status: "done", stage: `AI详情生成完成,共 ${finalGeneratedImages.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详情生成完成,已输出 ${finalGeneratedImages.length} 屏详情图`); } catch (error) { const message = error instanceof Error ? error.message : "AI详情生成失败"; updateDetailGenerationProgressState({ status: /已停止/.test(message) ? "idle" : "error", stage: message, current: completedDetailGenerationCount, total: pendingGeneration?.expectedCount || detailCount, startedAt, finishedAt: new Date().toISOString(), }); toast.error(message); } finally { setPendingGeneration(null); } } const handleDetailReplacementQueryTypeChange = useCallback( (queryType: AgentWorkflowReferenceQueryType) => { updateDetailReplacementState((current) => ({ ...current, queryType, candidates: current.queryType === queryType ? current.candidates : [], selectedCandidateId: current.queryType === queryType ? current.selectedCandidateId : null, references: current.queryType === queryType ? current.references : [], })); }, [updateDetailReplacementState] ); const handleDetailReplacementProductDetailChannelChange = useCallback( (channel: AgentWorkflowProductDetailChannel) => { updateDetailReplacementState((current) => ({ ...current, productDetailChannel: channel, })); }, [updateDetailReplacementState] ); const handleDetailReferenceUploadChange = useCallback( async (event: React.ChangeEvent) => { const inputElement = event.currentTarget; const rawFiles = Array.from(inputElement.files || []); const imageFiles = rawFiles.filter((file) => file.type.startsWith("image/")); if (rawFiles.length > 0 && imageFiles.length === 0) { toast.error("请上传图片文件"); inputElement.value = ""; return; } const availableSlotCount = Math.max( MAX_AGENT_MODE_REFERENCE_COUNT - detailReplacementReferenceCards.length, 0 ); if (availableSlotCount <= 0) { toast.error(`最多支持 ${MAX_AGENT_MODE_REFERENCE_COUNT} 张参考图,请先删除部分图片`); inputElement.value = ""; return; } const uploadFiles = imageFiles.slice(0, availableSlotCount); if (imageFiles.length > uploadFiles.length) { toast.warning(`已达到参考图上限,本次只上传前 ${uploadFiles.length} 张`); } if (uploadFiles.length === 0) { inputElement.value = ""; return; } setDetailReferenceUploadState({ isUploading: true, count: uploadFiles.length, }); try { const uploadedItems = await Promise.all( uploadFiles.map(async (file, index) => { const dimensions = await readLocalImageDimensions(file); const formData = new FormData(); formData.append("file", file); const response = await fetchUploadWithRetry(formData); const data = await response.json().catch(() => ({})); if (!response.ok || !data?.url) { throw new Error(data?.error || `${file.name || `第 ${index + 1} 张`}上传失败`); } return { index, file, url: String(data.url), previewUrl: String(data.previewUrl || data.thumbnailUrl || data.url || ""), width: dimensions.width, height: dimensions.height, }; }) ); const timestamp = Date.now(); const orderedItems = uploadedItems.slice().sort((left, right) => left.index - right.index); updateDetailReplacementState((current) => { const baseLength = current.references.length; const nextReferences = [ ...current.references, ...orderedItems.map((item, index) => ({ id: `detail-upload-${timestamp}-${index + 1}-${generateUUID().slice(0, 8)}`, label: item.file.name ? `上传详情图 ${baseLength + index + 1} · ${item.file.name}` : `上传详情图 ${baseLength + index + 1}`, imageUrl: item.url, previewUrl: item.previewUrl || item.url, width: item.width, height: item.height, candidateId: null, sortOrder: baseLength + index, passthrough: false, assignedProductIndex: null, })), ] .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) .map((card, index) => ({ ...card, sortOrder: index, })); return { ...current, references: nextReferences, }; }); toast.success(`已按文件选择顺序上传 ${orderedItems.length} 张参考详情图`); } catch (error) { toast.error(error instanceof Error ? error.message : "上传参考详情图失败"); } finally { setDetailReferenceUploadState({ isUploading: false, count: 0, }); inputElement.value = ""; } }, [detailReplacementReferenceCards.length, updateDetailReplacementState] ); const handleDetailReplacementSearch = useCallback(async () => { const normalized = detailReplacementQueryType === "productId" ? detailReplacementQueryInput.trim() : detailReplacementQueryInput.trim().toUpperCase(); const currentWorkflowBrandName = getCurrentWorkflowBrandName(); if (!currentWorkflowBrandName && detailReplacementQueryType !== "productId") { toast.error("请先在第一步完成品牌选择后再查询参考图"); return; } if (!normalized) { toast.error( `请输入${ detailReplacementQueryType === "productId" ? "商品ID" : detailReplacementQueryType === "sku" ? "SKU" : "SPU" }后再查询${detailReferenceSourceLabel}` ); return; } setDetailReplacementSearchState({ isLoading: true, lastQuery: normalized, }); try { const searchParams = new URLSearchParams({ keyword: normalized, queryType: detailReplacementQueryType, target: detailReferenceTarget, }); if (currentWorkflowBrandName) { searchParams.set("brandName", currentWorkflowBrandName); } if (detailReplacementQueryType === "productId") { searchParams.set("channel", detailReplacementProductDetailChannel); } 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: result.data.queryType || detailReplacementQueryType, productDetailChannel: result.data.channel || detailReplacementProductDetailChannel, 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, detailReplacementProductDetailChannel, detailReplacementQueryInput, detailReplacementQueryType, getCurrentWorkflowBrandName, updateDetailReplacementState, ]); const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => { updateDetailReplacementState((current) => { const nextCandidate = current.candidates.find((candidate) => candidate.id === candidateId) || null; return { ...current, selectedCandidateId: candidateId, references: buildReferenceCardsFromCandidate({ candidate: nextCandidate, target: 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 handleToggleDetailReferenceCardPassthrough = useCallback((cardId: string) => { updateDetailReplacementState((current) => ({ ...current, references: current.references.map((card) => card.id === cardId ? { ...card, passthrough: card.passthrough ? false : true, } : card ), })); }, [updateDetailReplacementState]); const handleClearDetailReferenceCards = useCallback(() => { updateDetailReplacementState((current) => ({ ...current, candidates: [], selectedCandidateId: null, references: [], })); }, [updateDetailReplacementState]); const handlePreparedDetailReferenceCards = useCallback( (preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => { const preparedById = new Map(preparedCards.map((card) => [card.id, card])); updateDetailReplacementState((current) => ({ ...current, references: current.references.map((card) => { const prepared = preparedById.get(card.id); if (!prepared) return card; return { ...card, previewUrl: prepared.previewUrl || card.previewUrl || card.imageUrl || null, width: prepared.width || card.width || null, height: prepared.height || card.height || null, }; }), })); }, [updateDetailReplacementState] ); const handleApplyStitchedDetailReferenceCards = useCallback( (nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => { updateDetailReplacementState((current) => ({ ...current, references: nextCards, })); }, [updateDetailReplacementState] ); const handleSkuReplacementSearch = useCallback(async () => { const normalized = skuReplacementQueryInput.trim().toUpperCase(); const currentWorkflowBrandName = getCurrentWorkflowBrandName(); if (!currentWorkflowBrandName) { 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, }); searchParams.set("brandName", currentWorkflowBrandName); 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 === "spu" || result.data.queryType === "sku" ? 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, getCurrentWorkflowBrandName, updateSkuReplacementState, ]); const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => { updateSkuReplacementState((current) => { const nextCandidate = current.candidates.find((candidate) => candidate.id === candidateId) || null; return { ...current, selectedCandidateId: candidateId, references: buildReferenceCardsFromCandidate({ candidate: nextCandidate, target: 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" />