diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 1d77b83b8..f8a65b5c0 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -233,6 +233,10 @@ function areResultCollectionsEqual( normalizeResultCollectionUrls(left.scene_generation), normalizeResultCollectionUrls(right.scene_generation) ) && + areStringArraysEqual( + normalizeResultCollectionUrls(left.detail_generation), + normalizeResultCollectionUrls(right.detail_generation) + ) && areStringArraysEqual( normalizeResultCollectionUrls(left.detail_replacement), normalizeResultCollectionUrls(right.detail_replacement) @@ -249,6 +253,7 @@ function getFirstResultUrlFromCollections( ) { return ( normalizeResultCollectionUrls(collections.scene_generation)[0] || + normalizeResultCollectionUrls(collections.detail_generation)[0] || normalizeResultCollectionUrls(collections.detail_replacement)[0] || normalizeResultCollectionUrls(collections.sku_replacement)[0] || null @@ -654,6 +659,9 @@ export default function CanvasEditorPage({ params }: PageProps) { scene_generation: normalizeResultCollectionUrls( agentProjectResultCollections.scene_generation ), + detail_generation: normalizeResultCollectionUrls( + agentProjectResultCollections.detail_generation + ), detail_replacement: normalizeResultCollectionUrls( agentProjectResultCollections.detail_replacement ), @@ -665,6 +673,9 @@ export default function CanvasEditorPage({ params }: PageProps) { scene_generation: normalizeResultCollectionUrls( agentProjectDeletedResultCollections.scene_generation ), + detail_generation: normalizeResultCollectionUrls( + agentProjectDeletedResultCollections.detail_generation + ), detail_replacement: normalizeResultCollectionUrls( agentProjectDeletedResultCollections.detail_replacement ), @@ -675,6 +686,7 @@ export default function CanvasEditorPage({ params }: PageProps) { resultImageUrls: Array.from( new Set([ ...normalizeResultCollectionUrls(agentProjectResultCollections.scene_generation), + ...normalizeResultCollectionUrls(agentProjectResultCollections.detail_generation), ...normalizeResultCollectionUrls(agentProjectResultCollections.detail_replacement), ...normalizeResultCollectionUrls(agentProjectResultCollections.sku_replacement), ]) diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index abadf10be..dcce03a97 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -44,6 +44,21 @@ 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 { getAgentRequiredUploadCount, normalizeAgentPresetModeConfig, @@ -52,6 +67,8 @@ import type { Attachment, ChatMessage } from "@/lib/types"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import type { + AgentDetailBriefForm, + AgentDetailGenerationProgressState, AgentGenerationProjectWorkspaceState, AgentGenerationProjectResultCollections, AgentGenerationResultModeType, @@ -160,7 +177,9 @@ function excludeDeletedResultUrls( } function getResultModeLabel(modeType: AgentGenerationResultModeType) { - return modeType === "detail_replacement" + return modeType === "detail_generation" + ? "AI详情" + : modeType === "detail_replacement" ? "详情替换" : modeType === "sku_replacement" ? "SKU替换" @@ -335,6 +354,103 @@ function getImageDimensions(url: string): Promise<{ }); } +type DirectDetailGenerationImage = { + originalUrl: string; + thumbnailUrl: string; + title?: string; + subtitle?: string; + copy?: string; +}; + +type DirectDetailReviewResult = { + reviewAction?: "pass" | "protocol_patch" | "regenerate_image"; + revisedPrompt?: string; + issues?: string[]; + uiValidation?: { + passed?: boolean; + issues?: string[]; + }; +}; + +const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600; +const AGENT_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000; + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function requestCanvasImageGeneration( + payload: { + prompt: string; + model?: string; + aspectRatio?: string; + imageSize?: string; + imageCount?: number; + negativePrompt?: string; + referenceImage?: string; + referenceImages?: string[]; + } +): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> { + const createResponse = await fetch("/api/canvas/generate/image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...payload, + async: true, + }), + }); + + const createResult = await createResponse.json().catch(() => ({})); + if (!createResponse.ok || !createResult?.success) { + throw new Error(createResult?.error || "创建生图任务失败"); + } + + if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) { + return { images: createResult.data.images }; + } + + const taskId = String(createResult?.taskId || "").trim(); + if (!taskId) { + throw new Error("创建生图任务失败:未返回任务ID"); + } + + const deadline = Date.now() + AGENT_IMAGE_TASK_TIMEOUT_MS; + let lastError = ""; + + while (Date.now() < deadline) { + await sleep(AGENT_IMAGE_TASK_POLL_INTERVAL_MS); + + const statusResponse = await fetch( + `/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`, + { cache: "no-store" } + ); + const statusResult = await statusResponse.json().catch(() => ({})); + + if (!statusResponse.ok || !statusResult?.success) { + lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`; + continue; + } + + const task = statusResult?.task; + const status = String(task?.status || "").trim(); + + if (status === "succeeded") { + const result = task?.result || {}; + const images = Array.isArray(result?.images) ? result.images : []; + if (images.length === 0) { + throw new Error("任务完成但未返回图片"); + } + return { images }; + } + + if (status === "failed") { + throw new Error(String(task?.error || "图片生成失败")); + } + } + + throw new Error(lastError || "图片生成超时,请稍后重试"); +} + function buildReferenceCardsFromCandidate(params: { candidate: AgentModeReferenceCandidate | null | undefined; target: AgentTaskDetailReferenceSource; @@ -581,6 +697,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { return ( Boolean(chatId) && (modeType === "scene_generation" || + modeType === "detail_generation" || modeType === "detail_replacement" || modeType === "sku_replacement") && Number.isFinite(expectedCount) && @@ -710,10 +827,12 @@ export function AgentGenerationWorkspaceDarkV2({ const selectNodes = useCanvasStore((state) => state.selectNodes); const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction); const fileInputRefs = useRef>([]); + const detailSlotInputRefs = useRef>([]); const hiddenPromptRef = useRef(null); const generatedImageKeysRef = useRef>(new Set()); const lastSyncedBrandModelUrlRef = useRef(null); const lastSyncedProductSlotKeysRef = useRef>([]); + const detailGenerationCancelRef = useRef(false); const workspaceStateSignatureRef = useRef(""); const [localAgentModeState, setLocalAgentModeState] = useState( () => createEmptyAgentModeWorkspaceState() @@ -775,11 +894,17 @@ export function AgentGenerationWorkspaceDarkV2({ }); const [pendingGeneration, setPendingGeneration] = useState(null); + const [detailGenerationRunResults, setDetailGenerationRunResults] = useState< + DirectDetailGenerationImage[] + >([]); const normalizedDeletedResultCollections = useMemo( () => ({ scene_generation: normalizeResultCollectionUrls( deletedResultCollections?.scene_generation ), + detail_generation: normalizeResultCollectionUrls( + deletedResultCollections?.detail_generation + ), detail_replacement: normalizeResultCollectionUrls( deletedResultCollections?.detail_replacement ), @@ -845,6 +970,15 @@ export function AgentGenerationWorkspaceDarkV2({ const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]); const referenceImages = workflowContext?.referenceImages || []; + const resolvedAgentModeState = agentModeState || localAgentModeState; + const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState; + const detailReplacementState = resolvedAgentModeState.detailReplacement; + const detailReplacementMethod = + detailReplacementState.method === "ai_detail_generate" + ? "ai_detail_generate" + : "reference_replace"; + const isDetailAiGenerationMethod = + Boolean(selectedAgentPresetId) && detailReplacementMethod === "ai_detail_generate"; const presetModeConfig = useMemo( () => normalizeAgentPresetModeConfig(selectedAgentPreset?.defaultParams), [selectedAgentPreset?.defaultParams] @@ -855,8 +989,12 @@ export function AgentGenerationWorkspaceDarkV2({ selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"; const isSkuReplacementPreset = selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "sku_replacement"; - const isReplacementPreset = isDetailReplacementPreset || isSkuReplacementPreset; - const currentResultModeType: AgentGenerationResultModeType = isDetailReplacementPreset + const isReplacementPreset = + (isDetailReplacementPreset && !isDetailAiGenerationMethod) || isSkuReplacementPreset; + const currentResultModeType: AgentGenerationResultModeType = + isDetailReplacementPreset && isDetailAiGenerationMethod + ? "detail_generation" + : isDetailReplacementPreset ? "detail_replacement" : isSkuReplacementPreset ? "sku_replacement" @@ -969,13 +1107,57 @@ export function AgentGenerationWorkspaceDarkV2({ : null; const attachmentSummaryReady = hasAgentUploadSlots && (requiredUploadCount === 0 || filledAttachmentCount >= requiredUploadCount); - const resolvedAgentModeState = agentModeState || localAgentModeState; - const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState; - const detailReplacementState = resolvedAgentModeState.detailReplacement; const detailReplacementQueryInput = detailReplacementState.query; const detailReplacementCandidates = detailReplacementState.candidates; const detailReplacementSelectedCandidateId = detailReplacementState.selectedCandidateId; const detailReplacementReferenceCards = detailReplacementState.references; + const detailGenerationMode = normalizeAgentDetailGenerationMode( + detailReplacementState.generationMode + ); + const detailGenerationBrief = sanitizeAgentDetailBrief(detailReplacementState.brief); + const detailGenerationSlots = + Array.isArray(detailReplacementState.slots) && + detailReplacementState.slots.length === AGENT_DETAIL_SLOT_CONFIGS.length + ? detailReplacementState.slots + : createEmptyAgentDetailSlots(); + const detailGenerationProgress = + (detailReplacementState.progress || { + status: "idle", + stage: "", + current: 0, + total: 0, + startedAt: null, + finishedAt: null, + }) as AgentDetailGenerationProgressState; + const detailGenerationProductCount = AGENT_DETAIL_PRODUCT_SLOT_INDEXES.filter( + (index) => Boolean(detailGenerationSlots[index]?.url) + ).length; + const detailGenerationBundleCount = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.filter( + (index) => Boolean(detailGenerationSlots[index]?.url) + ).length; + const detailGenerationModelReady = Boolean( + 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 < 5) { + issues.push(`产品图不足(${detailGenerationProductCount}/5)`); + } + if (!detailGenerationModelReady) { + issues.push("缺少模特图(1/1)"); + } + return issues; + }, [ + detailGenerationBrief.coreNeed, + detailGenerationBrief.targetAudience, + detailGenerationBrief.usageScenario, + detailGenerationModelReady, + detailGenerationProductCount, + ]); const skuReplacementState = resolvedAgentModeState.skuReplacement; const skuReplacementQueryInput = skuReplacementState.query; const skuReplacementQueryType = skuReplacementState.queryType; @@ -1282,7 +1464,6 @@ export function AgentGenerationWorkspaceDarkV2({ selectedBrandModel?.imageUrl, selectedBrandModel?.mimeType, ]); - const generatedGroups = useMemo(() => { return messages .filter((message) => message.role === "assistant") @@ -1343,17 +1524,23 @@ export function AgentGenerationWorkspaceDarkV2({ () => Array.from( new Set( - generatedGroups - .flatMap((group) => group.imageUrls) - .filter( - (url) => - !normalizedDeletedResultCollections.scene_generation.includes(url) && - !normalizedDeletedResultCollections.detail_replacement.includes(url) && - !normalizedDeletedResultCollections.sku_replacement.includes(url) - ) + [ + ...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) + ) ) ), - [generatedGroups, normalizedDeletedResultCollections] + [detailGenerationRunResults, generatedGroups, normalizedDeletedResultCollections, resultCollections] ); const currentSceneGeneratedUrls = useMemo( () => Array.from(new Set(generatedGroupsAfterLastUser.flatMap((group) => group.imageUrls))), @@ -1405,16 +1592,32 @@ export function AgentGenerationWorkspaceDarkV2({ ), [currentResultModeType, latestReplacementResults, normalizedDeletedResultCollections] ); - const currentLiveResultUrls = isReplacementPreset - ? latestReplacementResultUrls - : currentSceneGeneratedUrls; + const detailGenerationRunResultUrls = useMemo( + () => + detailGenerationRunResults + .map((item) => item.originalUrl) + .filter(Boolean) + .filter( + (url) => !normalizedDeletedResultCollections.detail_generation.includes(url) + ), + [detailGenerationRunResults, normalizedDeletedResultCollections.detail_generation] + ); + const currentLiveResultUrls = + currentResultModeType === "detail_generation" + ? detailGenerationRunResultUrls + : isReplacementPreset + ? latestReplacementResultUrls + : currentSceneGeneratedUrls; const transportIsGenerating = status === "streaming" || status === "submitted"; const pendingGenerationProgressCount = useMemo(() => { if (!pendingGeneration) { return 0; } - if (userMessageCount <= pendingGeneration.userTurnCountAtStart) { + if ( + pendingGeneration.modeType !== "detail_generation" && + userMessageCount <= pendingGeneration.userTurnCountAtStart + ) { return 0; } @@ -1422,9 +1625,14 @@ export function AgentGenerationWorkspaceDarkV2({ return currentSceneGeneratedUrls.length; } + if (pendingGeneration.modeType === "detail_generation") { + return detailGenerationRunResults.length; + } + return latestReplacementResults.length; }, [ currentSceneGeneratedUrls.length, + detailGenerationRunResults.length, latestReplacementResults.length, pendingGeneration, userMessageCount, @@ -1439,6 +1647,8 @@ export function AgentGenerationWorkspaceDarkV2({ 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); @@ -1449,6 +1659,10 @@ export function AgentGenerationWorkspaceDarkV2({ resultCollections?.scene_generation, normalizedDeletedResultCollections.scene_generation ), + detail_generation: excludeDeletedResultUrls( + resultCollections?.detail_generation, + normalizedDeletedResultCollections.detail_generation + ), detail_replacement: excludeDeletedResultUrls( resultCollections?.detail_replacement, normalizedDeletedResultCollections.detail_replacement @@ -1481,7 +1695,12 @@ export function AgentGenerationWorkspaceDarkV2({ ); const availableResultModeTypes = useMemo( () => - (["scene_generation", "detail_replacement", "sku_replacement"] as AgentGenerationResultModeType[]) + ([ + "scene_generation", + "detail_generation", + "detail_replacement", + "sku_replacement", + ] as AgentGenerationResultModeType[]) .filter( (modeType) => modeType === currentResultModeType || @@ -1566,37 +1785,55 @@ export function AgentGenerationWorkspaceDarkV2({ ).length, [skuResultCards] ); - const sceneRequestedImageCount = Math.max(1, imageParams.imageCount || 1); - const scenePendingRemainingCount = + const gridRequestedImageCount = + activeResultModeType === "detail_generation" + ? Math.max( + 1, + pendingGeneration?.modeType === "detail_generation" + ? pendingGeneration.expectedCount + : 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" && + pendingGeneration?.modeType === "detail_generation" + ? Math.max( + pendingGeneration.expectedCount - detailGenerationRunResults.length, + 0 + ) + : 0; + const gridPendingCardCount = + activeResultModeType === "scene_generation" || + activeResultModeType === "detail_generation" + ? gridPendingRemainingCount : 0; - const scenePendingCardCount = - activeResultModeType === "scene_generation" ? scenePendingRemainingCount : 0; - const sceneResultCardCount = Math.max( - activeResultModeUrls.length + scenePendingCardCount, - activeResultModeType === "scene_generation" && - pendingGeneration?.modeType === "scene_generation" - ? activeResultModeUrls.length + scenePendingRemainingCount + const gridResultCardCount = Math.max( + activeResultModeUrls.length + gridPendingCardCount, + (activeResultModeType === "scene_generation" || + activeResultModeType === "detail_generation") && + pendingGeneration?.modeType === activeResultModeType + ? activeResultModeUrls.length + gridPendingRemainingCount : activeResultModeType === currentResultModeType - ? sceneRequestedImageCount + ? gridRequestedImageCount : activeResultModeUrls.length, 1 ); - const sceneResultCards = useMemo( + const gridResultCards = useMemo( () => { const placeholderStartIndex = activeResultModeUrls.length; - return Array.from({ length: sceneResultCardCount }, (_, index) => { + return Array.from({ length: gridResultCardCount }, (_, index) => { const url = activeResultModeUrls[index] || null; const placeholderOffset = Math.max(index - placeholderStartIndex, 0); return { - id: url || `scene-result-placeholder-${activeResultModeType}-${index}`, + id: url || `grid-result-placeholder-${activeResultModeType}-${index}`, index, url, status: url ? ("completed" as const) - : scenePendingRemainingCount > 0 + : gridPendingRemainingCount > 0 ? placeholderOffset === 0 ? ("generating" as const) : ("queued" as const) @@ -1604,11 +1841,13 @@ export function AgentGenerationWorkspaceDarkV2({ }; }); }, - [activeResultModeType, activeResultModeUrls, scenePendingRemainingCount, sceneResultCardCount] + [activeResultModeType, activeResultModeUrls, gridPendingRemainingCount, gridResultCardCount] ); - const shouldShowSceneResultGrid = + const shouldShowGridResultSection = activeResultModeUrls.length > 0 || - (activeResultModeType === "scene_generation" && isSceneResultModeGenerating); + ((activeResultModeType === "scene_generation" && isSceneResultModeGenerating) || + (activeResultModeType === "detail_generation" && + isDetailGenerationResultModeGenerating)); const attachmentCount = attachments.filter(Boolean).length; const hasBrandModelSelection = Boolean( selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel") @@ -1791,7 +2030,10 @@ export function AgentGenerationWorkspaceDarkV2({ return; } - if (pendingGeneration.modeType === "scene_generation") { + if ( + pendingGeneration.modeType === "scene_generation" || + pendingGeneration.modeType === "detail_generation" + ) { return; } @@ -2048,6 +2290,10 @@ export function AgentGenerationWorkspaceDarkV2({ }, [addNode, nodes.length, onOpenCanvasEditor, requestImageNodeAction, selectNodes]); const handleSend = useCallback(() => { + if (isDetailReplacementPreset && isDetailAiGenerationMethod) { + void handleRunAiDetailGeneration(); + return; + } if (!selectedAgentPresetId) { toast.error("请先选择生图模式"); return; @@ -2111,10 +2357,12 @@ export function AgentGenerationWorkspaceDarkV2({ }, [ attachments, detailReplacementReferenceCards.length, + handleRunAiDetailGeneration, currentResultModeType, hasAgentUploadSlots, imageParams.imageCount, input, + isDetailAiGenerationMethod, isDetailReplacementPreset, isSkuReplacementPreset, requiredUploadCount, @@ -2128,6 +2376,7 @@ export function AgentGenerationWorkspaceDarkV2({ workflowContext?.summaryText, ]); const handleStopGeneration = useCallback(() => { + detailGenerationCancelRef.current = true; stop(); setPendingGeneration(null); setMessages((current) => current); @@ -2202,6 +2451,7 @@ export function AgentGenerationWorkspaceDarkV2({ const scrollToSection = useCallback((sectionId: string) => { if ( (sectionId === "agent-anchor-generate" || sectionId === "agent-anchor-results") && + !isDetailAiGenerationMethod && hasAgentUploadSlots && !hasSatisfiedSlotRequirement ) { @@ -2221,7 +2471,13 @@ export function AgentGenerationWorkspaceDarkV2({ return; } target.scrollIntoView({ behavior: "smooth", block: "start" }); - }, [hasAgentUploadSlots, hasMaterialStep, hasSatisfiedSlotRequirement, slotRequirementMessage]); + }, [ + hasAgentUploadSlots, + hasMaterialStep, + hasSatisfiedSlotRequirement, + isDetailAiGenerationMethod, + slotRequirementMessage, + ]); const updateDetailReplacementState = useCallback( ( updater: @@ -2254,6 +2510,531 @@ export function AgentGenerationWorkspaceDarkV2({ }, [emitAgentModeState, resolvedAgentModeState] ); + useEffect(() => { + if (!isDetailReplacementPreset) { + return; + } + + const secondaryReferenceImages = referenceImages + .filter((image) => image.source === "secondary") + .slice(0, AGENT_DETAIL_BUNDLE_SLOT_INDEXES.length); + + 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 + ); + AGENT_DETAIL_PRODUCT_SLOT_INDEXES.forEach((slotIndex, index) => { + const attachment = selectedProductAttachments[index]; + if (!attachment || nextSlots[slotIndex]) return; + nextSlots[slotIndex] = { + url: attachment.url, + name: attachment.name || AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || null, + contentType: attachment.contentType || "image/jpeg", + assetId: attachment.assetId || null, + }; + changed = true; + }); + if ( + AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 && + selectedModelAttachment && + !nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] + ) { + nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] = { + 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, + }; + changed = true; + } + AGENT_DETAIL_BUNDLE_SLOT_INDEXES.forEach((slotIndex, index) => { + const image = secondaryReferenceImages[index]; + if (!image || nextSlots[slotIndex]) return; + nextSlots[slotIndex] = { + url: image.url, + name: + image.name || + image.label || + AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || + null, + contentType: image.mimeType || "image/jpeg", + assetId: image.assetId || null, + }; + changed = true; + }); + + if (!changed) { + return current; + } + + return { + ...current, + brief: nextBrief, + slots: nextSlots, + }; + }); + }, [ + isDetailReplacementPreset, + referenceImages, + selectedModelAttachment, + selectedProductAttachments, + updateDetailReplacementState, + workflowContext?.priceBand, + workflowContext?.primarySku, + ]); + const updateDetailGenerationMethod = useCallback( + (method: "reference_replace" | "ai_detail_generate") => { + updateDetailReplacementState((current) => ({ + ...current, + method, + })); + }, + [updateDetailReplacementState] + ); + const updateDetailGenerationBriefField = useCallback( + (key: keyof AgentDetailBriefForm, value: string) => { + updateDetailReplacementState((current) => ({ + ...current, + brief: { + ...(current.brief || createEmptyAgentDetailBriefForm()), + [key]: value, + }, + })); + }, + [updateDetailReplacementState] + ); + const updateDetailGenerationSlot = useCallback( + (slotIndex: number, attachment: PickedAttachment | null) => { + updateDetailReplacementState((current) => { + const nextSlots = Array.from( + { length: AGENT_DETAIL_SLOT_CONFIGS.length }, + (_, index) => current.slots?.[index] || null + ); + nextSlots[slotIndex] = attachment + ? { + url: attachment.url, + name: attachment.name || null, + contentType: attachment.contentType || null, + assetId: attachment.assetId || null, + } + : null; + return { + ...current, + slots: nextSlots, + }; + }); + }, + [updateDetailReplacementState] + ); + const updateDetailGenerationProgressState = useCallback( + (progress: AgentDetailGenerationProgressState) => { + updateDetailReplacementState((current) => ({ + ...current, + progress, + })); + }, + [updateDetailReplacementState] + ); + const handleDetailGenerationSlotFileChange = useCallback( + async (slotIndex: number, event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + toast.error("请上传图片文件"); + event.currentTarget.value = ""; + return; + } + + try { + const formData = new FormData(); + formData.append("file", file); + const response = await fetchUploadWithRetry(formData); + const data = await response.json(); + if (!response.ok || !data?.url) { + throw new Error(data?.error || "上传失败"); + } + updateDetailGenerationSlot(slotIndex, { + url: data.url, + name: file.name, + contentType: file.type || "image/jpeg", + assetId: typeof data.assetId === "string" ? data.assetId : undefined, + }); + toast.success(`${AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材"}上传成功`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "上传失败"); + } finally { + event.currentTarget.value = ""; + } + }, + [updateDetailGenerationSlot] + ); + async function handleRunAiDetailGeneration() { + if (!selectedAgentPresetId) { + toast.error("请先选择生图模式"); + return; + } + if (detailGenerationMode !== "classic") { + toast.error("AGENT排版模式将在共享画布文字层能力接入后开放,当前请先使用经典详情"); + return; + } + if (detailGenerationValidationIssues.length > 0) { + toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数"); + return; + } + + const productAttachments = AGENT_DETAIL_PRODUCT_SLOT_INDEXES.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 = + AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 + ? detailGenerationSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] + : null; + + const basePrompt = detailGenerationBrief.productName + ? `${detailGenerationBrief.productName}详情页生成` + : "电商详情页生成"; + const demandHint = detailGenerationBrief.demandType + ? `,需求类型:${detailGenerationBrief.demandType}` + : ""; + const userSupplementPrompt = input.trim(); + const detailPrompt = + userSupplementPrompt || + `${basePrompt},面向${detailGenerationBrief.targetAudience},在${detailGenerationBrief.usageScenario}场景解决${detailGenerationBrief.coreNeed}${demandHint}`; + const workflowHint = workflowContext?.promptText + ? `\n\n【工作流风格要求】\n${workflowContext.promptText}` + : ""; + const detailPromptWithWorkflow = `${detailPrompt}${workflowHint}`.trim(); + const detailCount = extractAgentDetailFrameCount(detailPromptWithWorkflow); + const referenceImageNotes = AGENT_DETAIL_SLOT_CONFIGS.map((config, index) => { + const current = detailGenerationSlots[index]; + return `${index + 1}. ${config.label}${current?.name ? `(${current.name})` : ""}`; + }); + const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPromptWithWorkflow) + ? "en" + : "zh"; + const resolvedModelId = + selectedModelId !== "auto" + ? selectedModelId + : selectedAgentPreset?.defaultModelId || modelsData?.defaultModelId || undefined; + + detailGenerationCancelRef.current = false; + setDetailGenerationRunResults([]); + setActiveResultModeType("detail_generation"); + setSelectedResultUrl(null); + const startedAt = new Date().toISOString(); + updateDetailGenerationProgressState({ + status: "running", + stage: "Agent 正在解析需求并规划详情结构...", + current: 0, + total: Math.max(1, detailCount * 2 + 1), + startedAt, + finishedAt: null, + }); + setPendingGeneration({ + chatId, + modeType: "detail_generation", + expectedCount: detailCount, + userTurnCountAtStart: userMessageCount, + timestamp: Date.now(), + }); + + try { + const planResponse = await fetch("/api/canvas/detail-plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: detailPromptWithWorkflow, + count: detailCount, + generationMode: "classic", + referenceImages: detailGenerationSlots + .map((slot) => slot?.url || "") + .filter(Boolean) + .slice(0, 12), + referenceImageNotes, + modelImageUrl: modelAttachment?.url || undefined, + bundleImageUrls: bundleAttachments.map((item) => item.url), + outputLanguage, + brief: detailGenerationBrief, + }), + }); + const planResult = await planResponse.json().catch(() => ({})); + if (!planResponse.ok || !planResult?.success) { + throw new Error(planResult?.error || "详情分镜规划失败"); + } + + const planData = (planResult.data || {}) as { + summary?: string; + prompts?: string[]; + globalStyle?: Record; + screens?: Array>; + }; + const prompts = Array.isArray(planData.prompts) + ? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean) + : []; + if (prompts.length === 0) { + throw new Error("未获取到有效分镜提示词"); + } + + setPendingGeneration((current) => + current && current.chatId === chatId && current.modeType === "detail_generation" + ? { + ...current, + expectedCount: prompts.length, + } + : current + ); + updateDetailGenerationProgressState({ + status: "running", + stage: "分镜规划完成,开始逐屏生成...", + current: 1, + total: Math.max(1, prompts.length * 2 + 1), + startedAt, + finishedAt: null, + }); + + const generatedImages: DirectDetailGenerationImage[] = []; + for (let index = 0; index < prompts.length; index += 1) { + if (detailGenerationCancelRef.current) { + throw new Error("已停止当前 AI详情生成任务"); + } + + const screen = Array.isArray(planData.screens) ? planData.screens[index] || {} : {}; + const useModelForScene = Boolean(screen?.useModel); + const bundleShowcase = Boolean(screen?.bundleShowcase); + const bundleReason = String(screen?.bundleReason || "").trim(); + const promptText = String(screen?.visualPrompt || prompts[index] || "").trim(); + const generationStepCurrent = 2 + index * 2; + const reviewStepCurrent = generationStepCurrent + 1; + + updateDetailGenerationProgressState({ + status: "running", + stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`, + current: generationStepCurrent, + total: Math.max(1, prompts.length * 2 + 1), + startedAt, + finishedAt: null, + }); + + const globalStyle = planData.globalStyle || {}; + const styleDirective = [ + globalStyle.designLanguage ? `全局风格:${globalStyle.designLanguage}` : "", + globalStyle.typography?.fontFamily + ? `字体家族统一:${globalStyle.typography.fontFamily}` + : "", + globalStyle.typography?.title ? `标题规范:${globalStyle.typography.title}` : "", + globalStyle.typography?.subtitle + ? `副标题规范:${globalStyle.typography.subtitle}` + : "", + globalStyle.typography?.body ? `正文规范:${globalStyle.typography.body}` : "", + globalStyle.layout?.grid ? `布局栅格:${globalStyle.layout.grid}` : "", + globalStyle.layout?.safeArea ? `安全区:${globalStyle.layout.safeArea}` : "", + globalStyle.layout?.rhythm ? `留白节奏:${globalStyle.layout.rhythm}` : "", + globalStyle.palette ? `配色:${globalStyle.palette}` : "", + ] + .filter(Boolean) + .join(";"); + const styleAnchorUrl = + index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null; + const generationReferenceImagesBase = [ + ...productAttachments.map((item) => item.url), + ...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []), + ...(useModelForScene && modelAttachment?.url ? [modelAttachment.url] : []), + ...(styleAnchorUrl ? [styleAnchorUrl] : []), + ].filter(Boolean); + + const generationPrompt = sanitizeDetailImageModelPrompt( + [ + promptText, + styleDirective ? `全局样式约束:${styleDirective}` : "", + screen?.screenType + ? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)` + : "", + index === 0 + ? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。" + : "", + useModelForScene + ? "人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。" + : "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。", + screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "", + bundleShowcase + ? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。" + : "", + bundleReason ? `搭配依据:${bundleReason}` : "", + screen?.title ? `本屏标题:${screen.title}` : "", + screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "", + screen?.copy ? `本屏文案说明:${screen.copy}` : "", + screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "", + screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "", + screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "", + screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "", + screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "", + `输出为竖版电商详情图,尺寸固定 ${AGENT_DETAIL_TARGET_WIDTH}x${AGENT_DETAIL_TARGET_HEIGHT} 像素。`, + "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。", + ] + .filter(Boolean) + .join("\n\n") + ); + + const firstPass = await requestCanvasImageGeneration({ + prompt: generationPrompt, + model: resolvedModelId, + aspectRatio: "9:16", + imageSize: imageParams.imageSize, + imageCount: 1, + negativePrompt: imageParams.negativePrompt, + referenceImage: productAttachments[0]?.url, + referenceImages: generationReferenceImagesBase, + }); + const firstPassImage = firstPass.images[0]; + if (!firstPassImage?.originalUrl && !firstPassImage?.thumbnailUrl) { + throw new Error(`第 ${index + 1} 屏生成失败`); + } + + let finalImage = { + originalUrl: String( + firstPassImage.originalUrl || firstPassImage.thumbnailUrl || "" + ).trim(), + thumbnailUrl: String( + firstPassImage.thumbnailUrl || firstPassImage.originalUrl || "" + ).trim(), + }; + + updateDetailGenerationProgressState({ + status: "running", + stage: `第 ${index + 1}/${prompts.length} 屏首稿完成,正在审稿优化...`, + current: reviewStepCurrent, + total: Math.max(1, prompts.length * 2 + 1), + startedAt, + finishedAt: null, + }); + + try { + const reviewResponse = await fetch("/api/canvas/detail-review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + outputLanguage, + generationMode: "classic", + imageUrl: finalImage.originalUrl || finalImage.thumbnailUrl, + referenceImages: generationReferenceImagesBase.slice(0, 6), + brief: { + productName: detailGenerationBrief.productName, + targetAudience: detailGenerationBrief.targetAudience, + usageScenario: detailGenerationBrief.usageScenario, + coreNeed: detailGenerationBrief.coreNeed, + coreSellingPoint: detailGenerationBrief.coreSellingPoint, + }, + globalStyle: planData.globalStyle, + screen: { + index: index + 1, + title: String(screen?.title || "").trim(), + subtitle: String(screen?.subtitle || "").trim(), + copy: String(screen?.copy || "").trim(), + layoutNote: String(screen?.layoutNote || "").trim(), + visualPrompt: generationPrompt, + screenType: String(screen?.screenType || "").trim(), + }, + }), + }); + const reviewResult = (await reviewResponse.json().catch(() => ({}))) as { + success?: boolean; + data?: DirectDetailReviewResult; + }; + + if ( + reviewResponse.ok && + reviewResult?.success && + reviewResult.data?.reviewAction === "regenerate_image" && + typeof reviewResult.data?.revisedPrompt === "string" && + reviewResult.data.revisedPrompt.trim().length > 20 + ) { + const regenerated = await requestCanvasImageGeneration({ + prompt: sanitizeDetailImageModelPrompt(reviewResult.data.revisedPrompt.trim()), + model: resolvedModelId, + aspectRatio: "9:16", + imageSize: imageParams.imageSize, + imageCount: 1, + negativePrompt: imageParams.negativePrompt, + referenceImage: productAttachments[0]?.url, + referenceImages: [ + ...generationReferenceImagesBase, + finalImage.originalUrl || finalImage.thumbnailUrl, + ].filter(Boolean), + }); + const regeneratedImage = regenerated.images[0]; + if (regeneratedImage?.originalUrl || regeneratedImage?.thumbnailUrl) { + finalImage = { + originalUrl: String( + regeneratedImage.originalUrl || regeneratedImage.thumbnailUrl || "" + ).trim(), + thumbnailUrl: String( + regeneratedImage.thumbnailUrl || regeneratedImage.originalUrl || "" + ).trim(), + }; + } + } + } catch (reviewError) { + console.warn("[AgentGenerationWorkspaceDarkV2] AI detail review failed:", reviewError); + } + + const nextImage: DirectDetailGenerationImage = { + originalUrl: finalImage.originalUrl, + thumbnailUrl: finalImage.thumbnailUrl, + title: String(screen?.title || "").trim() || `详情图 ${index + 1}`, + subtitle: String(screen?.subtitle || "").trim(), + copy: String(screen?.copy || "").trim(), + }; + generatedImages.push(nextImage); + setDetailGenerationRunResults([...generatedImages]); + } + + updateDetailGenerationProgressState({ + status: "done", + stage: `AI详情生成完成,共 ${generatedImages.length}/${prompts.length} 屏`, + current: Math.max(1, prompts.length * 2 + 1), + total: Math.max(1, prompts.length * 2 + 1), + startedAt, + finishedAt: new Date().toISOString(), + }); + toast.success(`AI详情生成完成,已输出 ${generatedImages.length} 屏详情图`); + } catch (error) { + const message = + error instanceof Error ? error.message : "AI详情生成失败"; + updateDetailGenerationProgressState({ + status: /已停止/.test(message) ? "idle" : "error", + stage: message, + current: detailGenerationRunResults.length, + total: pendingGeneration?.expectedCount || detailCount, + startedAt, + finishedAt: new Date().toISOString(), + }); + toast.error(message); + } finally { + setPendingGeneration(null); + } + } const handleDetailReplacementSearch = useCallback(async () => { const normalized = detailReplacementQueryInput.trim().toUpperCase(); if (!workflowStoreName) { @@ -2675,10 +3456,12 @@ export function AgentGenerationWorkspaceDarkV2({

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

- {isReplacementPreset ? ( + {isDetailReplacementPreset || isSkuReplacementPreset ? (

{isDetailReplacementPreset - ? "当前模板属于详情替换模式,结果区会按参考图顺序逐屏展示。" + ? isDetailAiGenerationMethod + ? "当前模板已切换到 AI详情生成,系统会直接复用智能规划详情链路生成整套详情图。" + : "当前模板属于详情替换模式,结果区会按参考图顺序逐屏展示。" : "当前模板属于 SKU 替换模式,结果区会按参考图顺序一对一展示。"}

) : null} @@ -2789,16 +3572,246 @@ export function AgentGenerationWorkspaceDarkV2({

- 补充要求 + {isDetailAiGenerationMethod ? "详情需求补充" : "补充要求"}