From b6db6a671c4317044057e64b9bfad23e761573db Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Mar 2026 22:17:11 +0800 Subject: [PATCH] feat: upgrade agent workflow generation modes --- .../agent-workflow-settings-client.tsx | 4 +- app/(chat)/api/chat/route.ts | 553 ++++- app/(chat)/api/chat/schema.ts | 20 + .../agent-workflow/material-search/route.ts | 66 +- .../agent-workflow/reference-search/route.ts | 275 +++ app/canvas/[id]/page.tsx | 47 +- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 1866 +++++++++++++++-- components/agents/AgentPresetCardDark.tsx | 13 +- components/agents/AgentPresetDialog.tsx | 594 +++++- .../agents/AgentWorkflowMaterialPicker.tsx | 119 +- .../AgentWorkflowMaterialSelectionPanel.tsx | 87 +- .../agents/AgentWorkflowPanelDarkV2.tsx | 28 +- .../agents/AgentWorkflowShowcaseBanner.tsx | 16 +- components/canvas/nodes/ImageNode.tsx | 40 +- lib/agents/mode-workspace-state.ts | 131 ++ lib/agents/preset-mode-config.ts | 164 ++ lib/agents/workflow-material-config.ts | 30 +- lib/db/queries.ts | 33 +- store/canvas/index.ts | 2 +- types/agent.ts | 92 + 20 files changed, 3877 insertions(+), 303 deletions(-) create mode 100644 app/api/agent-workflow/reference-search/route.ts create mode 100644 lib/agents/mode-workspace-state.ts create mode 100644 lib/agents/preset-mode-config.ts diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 04cb970fe..ab8a0ae7b 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -954,7 +954,7 @@ export function AgentWorkflowSettingsClient({
素材搜索接口 - 配置 SPU/SKU 搜图接口 + 配置白底图、详情参考图、SKU 参考图共用接口
@@ -963,7 +963,7 @@ export function AgentWorkflowSettingsClient({
启用素材搜索
- 开启后支持搜索白底素材图 + 开启后支持搜索白底素材图、详情参考图和 SKU 参考图
; + +function normalizeAgentModeReferences( + payload?: PostRequestBody["agentModePayload"], +): AgentReplacementModePayload["references"] { + if (!payload?.references?.length) return []; + return payload.references + .map((item) => ({ + id: String(item.id || "").trim(), + label: String(item.label || "").trim() || "参考图", + imageUrl: String(item.imageUrl || "").trim(), + candidateId: String(item.candidateId || "").trim() || null, + sortOrder: Number.isFinite(Number(item.sortOrder)) + ? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder)))) + : 0, + assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex)) + ? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex)))) + : null, + })) + .filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl)) + .sort((left, right) => left.sortOrder - right.sortOrder); +} + +function buildAgentReplacementPrompt(params: { + basePrompt: string; + modeType: "detail_replacement" | "sku_replacement"; + referenceLabel: string; + referenceIndex: number; + totalReferences: number; + hasModelImage: boolean; + pairedProductIndex?: number; +}) { + const instructionLines = + params.modeType === "detail_replacement" + ? [ + "【执行模式】详情替换", + `当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`, + "请严格保留最后一张参考图的版式结构、镜头关系、构图、光影氛围、留白位置和信息层级,只替换其中的商品主体。", + "前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请在需要人物展示时优先参考模特图。", + "不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式元素。", + ] + : [ + "【执行模式】SKU替换", + `当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张 SKU 参考图:${params.referenceLabel}`, + params.pairedProductIndex !== undefined + ? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。` + : "请使用输入商品图作为本张参考图的一对一替换主体。", + "请严格保留最后一张参考图的场景、机位、透视、光影和构图关系,只替换商品主体本身。", + "输出要自然融合,不要改变参考图原有风格和景别。", + ]; + + if (params.hasModelImage) { + instructionLines.push("如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致。"); + } + + return [params.basePrompt, instructionLines.join("\n")] + .filter(Boolean) + .join("\n\n") + .trim(); +} + const MODEL_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -2980,6 +3045,7 @@ export async function POST(request: Request) { hiddenPrompt, autoContext, contextPayload, + agentModePayload, deepThinkingEnabled, debugProtocol, }: { @@ -2991,6 +3057,7 @@ export async function POST(request: Request) { hiddenPrompt?: string; autoContext?: string; contextPayload?: PostRequestBody["contextPayload"]; + agentModePayload?: PostRequestBody["agentModePayload"]; deepThinkingEnabled?: boolean; debugProtocol?: boolean; } = requestBody; @@ -5376,6 +5443,30 @@ export async function POST(request: Request) { const orderedSlots = [...(selectedAgentPreset.slots || [])].sort( (left, right) => left.slotIndex - right.slotIndex, ); + const presetModeConfig = normalizeAgentPresetModeConfig(selectedAgentPreset.defaultParams); + const isSceneGenerationPreset = + selectedAgentPreset.type === "image" && + presetModeConfig.modeType === "scene_generation"; + const isDetailReplacementPreset = + selectedAgentPreset.type === "image" && + presetModeConfig.modeType === "detail_replacement"; + const isSkuReplacementPreset = + selectedAgentPreset.type === "image" && + presetModeConfig.modeType === "sku_replacement"; + const replacementProductSlotCount = isDetailReplacementPreset + ? presetModeConfig.detail.productImageSlotCount + : isSkuReplacementPreset + ? presetModeConfig.sku.productImageSlotCount + : 0; + const replacementModelSlotEnabled = isDetailReplacementPreset + ? presetModeConfig.detail.includeModelSlot + : isSkuReplacementPreset + ? presetModeConfig.sku.includeModelSlot + : false; + const replacementReferences = normalizeAgentModeReferences(agentModePayload); + const isMatchingReplacementPayload = + (isDetailReplacementPreset && agentModePayload?.modeType === "detail_replacement") || + (isSkuReplacementPreset && agentModePayload?.modeType === "sku_replacement"); if (orderedSlots.length === 0) { return NextResponse.json( { @@ -5385,9 +5476,33 @@ export async function POST(request: Request) { { status: 400 }, ); } + if ((isDetailReplacementPreset || isSkuReplacementPreset) && !isMatchingReplacementPayload) { + return NextResponse.json( + { + code: "bad_request:api", + message: "当前模式缺少执行上下文,请返回第二步重新选择参考图后再试", + }, + { status: 400 }, + ); + } + if ((isDetailReplacementPreset || isSkuReplacementPreset) && replacementReferences.length === 0) { + return NextResponse.json( + { + code: "bad_request:api", + message: isDetailReplacementPreset + ? "详情替换模式至少需要 1 张详情参考图" + : "SKU替换模式至少需要 1 张 SKU 参考图", + }, + { status: 400 }, + ); + } const uploadedImageParts = extractCurrentMessageImageParts(message.parts as any[]); - const requiredUploadCount = 3; + const requiredUploadCount = getAgentRequiredUploadCount({ + presetType: selectedAgentPreset.type, + defaultParams: selectedAgentPreset.defaultParams || null, + fallback: 3, + }); if (uploadedImageParts.length < requiredUploadCount) { return NextResponse.json( @@ -5456,6 +5571,16 @@ export async function POST(request: Request) { agentPresetId, requiredUploadCount, uploadedImageUrls: uploadedImageParts.map((part) => part.url), + agentModePayload: + isDetailReplacementPreset || isSkuReplacementPreset + ? { + modeType: agentModePayload?.modeType || null, + query: agentModePayload?.query || null, + queryType: agentModePayload?.queryType || null, + selectedCandidateId: agentModePayload?.selectedCandidateId || null, + referenceCount: replacementReferences.length, + } + : null, }, }); @@ -5488,7 +5613,11 @@ export async function POST(request: Request) { modelName: selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name, - imageCount: orderedSlots.length, + imageCount: isSceneGenerationPreset + ? Math.max(1, effectiveImageParams?.imageCount || 1) + : isDetailReplacementPreset || isSkuReplacementPreset + ? replacementReferences.length + : orderedSlots.length, }, "agent-preset:image:status-processing", ); @@ -5503,6 +5632,425 @@ export async function POST(request: Request) { .map((part) => part.assetId) .filter((assetId): assetId is string => Boolean(assetId)); + if ( + selectedAgentPreset.type === "image" && + (isDetailReplacementPreset || isSkuReplacementPreset) + ) { + const replacementSlot = orderedSlots[0]; + const replacementAssistantMessageId = generateUUID(); + const replacementTemplateReferenceImageUrls = Array.from( + new Set( + (replacementSlot?.referenceAssets || []) + .map((asset) => String(asset.originalUrl || "").trim()) + .filter(Boolean), + ), + ); + const productInputImageUrls = sharedUploadedImageUrls.slice( + 0, + Math.max(1, replacementProductSlotCount) + ); + const modelInputImageUrl = replacementModelSlotEnabled + ? sharedUploadedImageUrls[Math.max(0, replacementProductSlotCount)] || null + : null; + const sourceAssetIds = Array.from( + new Set( + [ + ...sharedUploadedAssetIds, + ...(replacementSlot?.referenceAssetIds || []), + ].filter(Boolean), + ), + ); + const baseReplacementPrompt = buildAgentSlotPrompt({ + workflowPrompt: hiddenPromptText || null, + slotPrompt: + replacementSlot?.promptTemplate || selectedAgentPreset.promptTemplate, + slotTitle: selectedAgentPreset.name, + userSupplementPrompt: sanitizedUserSupplementPrompt || null, + }); + + if (productInputImageUrls.length === 0) { + throw new Error("替换模式至少需要 1 张输入商品图"); + } + + const mergedImageParams = { + ...(selectedAgentPreset.defaultParams || {}), + ...(replacementSlot?.defaultParams || {}), + } as Record; + const resolvedModelRef = + String(selectedChatModel || "").trim() && + String(selectedChatModel || "").trim() !== "auto" + ? String(selectedChatModel || "").trim() + : presetModelId; + const resolvedAspectRatio = + typeof mergedImageParams.aspectRatio === "string" + ? mergedImageParams.aspectRatio + : effectiveImageParams?.aspectRatio || defaultImageQualityParams.aspectRatio; + const resolvedImageSize = + typeof mergedImageParams.imageSize === "string" + ? mergedImageParams.imageSize + : effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize; + const replacementSections: string[] = []; + const replacementResultImages: Array<{ + originalUrl: string; + thumbnailUrl?: string | null; + }> = []; + let resolvedModelDisplayName = + selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name; + const stopKeepalive = startStreamKeepalive( + writer, + "agent-preset:replacement", + 12_000, + ); + + try { + for (const [referenceIndex, reference] of replacementReferences.entries()) { + const pairedProductIndex = + isSkuReplacementPreset && productInputImageUrls.length > 0 + ? Math.min( + typeof reference.assignedProductIndex === "number" + ? reference.assignedProductIndex + : referenceIndex, + productInputImageUrls.length - 1 + ) + : undefined; + const currentProductImageUrls = + pairedProductIndex !== undefined + ? [productInputImageUrls[pairedProductIndex]!] + : productInputImageUrls; + const sourceImageUrls = Array.from( + new Set( + [ + ...currentProductImageUrls, + ...(modelInputImageUrl ? [modelInputImageUrl] : []), + ...replacementTemplateReferenceImageUrls, + reference.imageUrl, + ].filter(Boolean), + ), + ); + const replacementPrompt = buildAgentReplacementPrompt({ + basePrompt: + baseReplacementPrompt || + (isDetailReplacementPreset ? "执行详情替换" : "执行 SKU 替换"), + modeType: isDetailReplacementPreset + ? "detail_replacement" + : "sku_replacement", + referenceLabel: reference.label, + referenceIndex, + totalReferences: replacementReferences.length, + hasModelImage: Boolean(modelInputImageUrl), + pairedProductIndex, + }); + + try { + const result = await generateCanvasImage({ + prompt: replacementPrompt, + negativePrompt: + typeof mergedImageParams.negativePrompt === "string" + ? mergedImageParams.negativePrompt + : undefined, + model: resolvedModelRef, + aspectRatio: resolvedAspectRatio, + imageSize: resolvedImageSize, + imageCount: 1, + referenceImages: sourceImageUrls, + }); + resolvedModelDisplayName = result.model || resolvedModelDisplayName; + const firstImage = result.images[0]; + if (!firstImage) { + throw new Error("未返回结果图片"); + } + replacementResultImages.push(firstImage); + replacementSections.push( + `### ${reference.label}\n\n${buildAgentToolImagesMarkdown([ + { + originalUrl: firstImage.originalUrl, + thumbnailUrl: firstImage.thumbnailUrl, + imageSize: resolvedImageSize, + }, + ])}`, + ); + successCount += 1; + } catch (replacementItemError) { + console.error("[Chat API] Agent replacement item execution failed:", { + referenceIndex, + referenceLabel: reference.label, + error: replacementItemError, + }); + replacementSections.push( + `### ${reference.label}\n\n执行失败:${ + replacementItemError instanceof Error + ? replacementItemError.message + : "请稍后重试" + }`, + ); + } + } + } finally { + stopKeepalive(); + } + + const assistantText = prependImageModelBadge( + [ + `### ${selectedAgentPreset.name}`, + ...replacementSections, + ] + .filter(Boolean) + .join("\n\n"), + resolvedModelDisplayName, + ); + + await saveMessages({ + messages: [ + { + id: replacementAssistantMessageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: assistantText }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + + if (replacementResultImages.length > 0) { + await saveGeneratedImages( + assistantText, + session.user.id, + id, + replacementAssistantMessageId, + baseReplacementPrompt || selectedAgentPreset.name, + resolvedModelRef, + { + sourceAssetIds, + sourceImageUrls: Array.from( + new Set([ + ...sharedUploadedImageUrls, + ...replacementTemplateReferenceImageUrls, + ...replacementReferences.map((item) => item.imageUrl), + ]), + ), + relationType: "edited_from", + sourceType: isDetailReplacementPreset + ? "detail_replacement" + : "sku_replacement", + projectId: contextPayload?.projectId || null, + userPrompt: requestTextContent, + optimizedPrompt: baseReplacementPrompt || undefined, + requestedImageCount: replacementResultImages.length, + aspectRatio: resolvedAspectRatio, + imageSize: resolvedImageSize, + }, + ); + } + + writeAssistantTextChunk( + writer, + assistantText, + isDetailReplacementPreset + ? "agent-preset:image:detail-replacement" + : "agent-preset:image:sku-replacement", + replacementAssistantMessageId, + ); + if (contextPayload?.source === "canvas" && replacementResultImages.length > 0) { + writeCanvasMediaChunk( + writer, + { + messageId: replacementAssistantMessageId, + imageUrls: replacementResultImages.map((image) => image.originalUrl), + prompt: baseReplacementPrompt || selectedAgentPreset.name, + sourceImageUrls: Array.from( + new Set([ + ...sharedUploadedImageUrls, + ...replacementTemplateReferenceImageUrls, + ...replacementReferences.map((item) => item.imageUrl), + ]), + ), + agentPresetId: selectedAgentPreset.id, + agentPresetName: selectedAgentPreset.name, + agentCategoryId: + selectedAgentPreset.category?.id || + selectedAgentPreset.categoryId || + null, + agentCategoryName: selectedAgentPreset.category?.name || null, + agentExecutionSnapshotId: presetSnapshot.id, + }, + isDetailReplacementPreset + ? "agent-preset:image:detail-replacement:canvas-media" + : "agent-preset:image:sku-replacement:canvas-media", + ); + } + } else if (selectedAgentPreset.type === "image" && isSceneGenerationPreset) { + const sceneSlot = orderedSlots[0]; + const sceneAssistantMessageId = generateUUID(); + const sceneReferenceImageUrls = Array.from( + new Set( + (sceneSlot?.referenceAssets || []) + .map((asset) => String(asset.originalUrl || "").trim()) + .filter(Boolean), + ), + ); + const sourceImageUrls = Array.from( + new Set([...sharedUploadedImageUrls, ...sceneReferenceImageUrls].filter(Boolean)), + ); + const sourceAssetIds = Array.from( + new Set( + [...sharedUploadedAssetIds, ...(sceneSlot?.referenceAssetIds || [])].filter(Boolean), + ), + ); + const scenePrompt = buildAgentSlotPrompt({ + workflowPrompt: hiddenPromptText || null, + slotPrompt: sceneSlot?.promptTemplate || selectedAgentPreset.promptTemplate, + slotTitle: selectedAgentPreset.name, + userSupplementPrompt: sanitizedUserSupplementPrompt || null, + }); + + try { + const mergedImageParams = { + ...(selectedAgentPreset.defaultParams || {}), + ...(sceneSlot?.defaultParams || {}), + } as Record; + const resolvedModelRef = + presetModeConfig.scene.allowModelOverride && + String(selectedChatModel || "").trim() && + String(selectedChatModel || "").trim() !== "auto" + ? String(selectedChatModel || "").trim() + : presetModelId; + const resolvedImageCount = Math.max(1, effectiveImageParams?.imageCount || 1); + const result = await generateCanvasImage({ + prompt: scenePrompt || `生成${selectedAgentPreset.name}`, + negativePrompt: + typeof mergedImageParams.negativePrompt === "string" + ? mergedImageParams.negativePrompt + : undefined, + model: resolvedModelRef, + aspectRatio: + typeof mergedImageParams.aspectRatio === "string" + ? mergedImageParams.aspectRatio + : effectiveImageParams?.aspectRatio || defaultImageQualityParams.aspectRatio, + imageSize: + typeof mergedImageParams.imageSize === "string" + ? mergedImageParams.imageSize + : effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize, + imageCount: resolvedImageCount, + referenceImages: sourceImageUrls, + }); + + const imageMarkdown = buildAgentToolImagesMarkdown( + result.images.map((image) => ({ + originalUrl: image.originalUrl, + thumbnailUrl: image.thumbnailUrl, + imageSize: + typeof mergedImageParams.imageSize === "string" + ? mergedImageParams.imageSize + : effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize, + })), + ); + const assistantText = prependImageModelBadge( + [`### ${selectedAgentPreset.name}`, imageMarkdown].filter(Boolean).join("\n\n"), + result.model, + ); + + await saveMessages({ + messages: [ + { + id: sceneAssistantMessageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: assistantText }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + + await saveGeneratedImages( + assistantText, + session.user.id, + id, + sceneAssistantMessageId, + scenePrompt || selectedAgentPreset.name, + result.modelRef || resolvedModelRef, + { + sourceAssetIds, + sourceImageUrls, + relationType: "generated_from", + sourceType: + sourceImageUrls.length > 0 ? "reference_generate" : "generated", + projectId: contextPayload?.projectId || null, + userPrompt: requestTextContent, + optimizedPrompt: scenePrompt || undefined, + requestedImageCount: resolvedImageCount, + }, + ); + + writeAssistantTextChunk( + writer, + assistantText, + "agent-preset:image:scene-generation", + sceneAssistantMessageId, + ); + if (contextPayload?.source === "canvas") { + writeCanvasMediaChunk( + writer, + { + messageId: sceneAssistantMessageId, + imageUrls: result.images.map((image) => image.originalUrl), + prompt: scenePrompt || selectedAgentPreset.name, + sourceImageUrls, + agentPresetId: selectedAgentPreset.id, + agentPresetName: selectedAgentPreset.name, + agentCategoryId: + selectedAgentPreset.category?.id || + selectedAgentPreset.categoryId || + null, + agentCategoryName: selectedAgentPreset.category?.name || null, + agentExecutionSnapshotId: presetSnapshot.id, + }, + "agent-preset:image:scene-generation:canvas-media", + ); + } + + successCount = result.images.length; + } catch (sceneError) { + console.error("[Chat API] Agent preset scene generation failed:", sceneError); + const errorMessage = `### ${selectedAgentPreset.name}\n\n执行失败:${ + sceneError instanceof Error ? sceneError.message : "请稍后重试" + }`; + await saveMessages({ + messages: [ + { + id: sceneAssistantMessageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: errorMessage }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + writeAssistantTextChunk( + writer, + errorMessage, + "agent-preset:error:scene-generation", + sceneAssistantMessageId, + ); + } + } else { for (const [slotOrder, slot] of orderedSlots.entries()) { const slotLabel = String(slot.title || slot.uploadLabel || "").trim() || `第${slotOrder + 1}张`; @@ -5841,6 +6389,7 @@ export async function POST(request: Request) { ); } } + } try { await persistAgentPresetTurn({ diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index eb57f24a2..0be3a5e6a 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -71,6 +71,25 @@ const contextPayloadSchema = z }) .optional(); +const agentModeReferenceSchema = z.object({ + id: z.string().min(1).max(128), + label: z.string().min(1).max(255), + imageUrl: z.string().url(), + candidateId: z.string().min(1).max(128).nullable().optional(), + sortOrder: z.number().min(0).max(999).optional(), + assignedProductIndex: z.number().min(0).max(9).nullable().optional(), +}); + +const agentModePayloadSchema = z + .object({ + modeType: z.enum(["detail_replacement", "sku_replacement"]), + query: z.string().max(64).optional(), + queryType: z.enum(["sku", "spu"]).optional(), + selectedCandidateId: z.string().min(1).max(128).nullable().optional(), + references: z.array(agentModeReferenceSchema).max(40), + }) + .optional(); + export const postRequestBodySchema = z.object({ id: z.string().uuid(), message: z.object({ @@ -89,6 +108,7 @@ export const postRequestBodySchema = z.object({ videoParams: videoParamsSchema, // 视频参数 imageParams: imageParamsSchema, // 🆕 图片生成参数 contextPayload: contextPayloadSchema, + agentModePayload: agentModePayloadSchema, }); export type PostRequestBody = z.infer; diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts index 415c7f84a..4832eb2fa 100644 --- a/app/api/agent-workflow/material-search/route.ts +++ b/app/api/agent-workflow/material-search/route.ts @@ -13,6 +13,7 @@ export const maxDuration = 180; const querySchema = z.object({ keyword: z.string().trim().min(1).max(64), source: z.enum(["primary", "secondary"]).optional(), + queryType: z.enum(["sku", "spu"]).optional(), }); type ExternalImageRecord = { @@ -27,8 +28,20 @@ type ExternalSearchResponse = { success?: boolean; data?: { sku?: string; + spu?: string; materialId?: string; productModel?: string; + skuCount?: number; + skuList?: Array<{ + skuName?: string; + skuId?: string; + hasWhiteBackground?: boolean; + whiteBackgroundCount?: number; + previewImage?: { + downloadUrl?: string; + fileName?: string; + } | null; + }>; whiteBackground?: { fixed?: ExternalImageRecord[]; other?: ExternalImageRecord[]; @@ -147,7 +160,12 @@ async function importExternalImage(params: { } function mapUpstreamErrorStatus(code: string) { - if (code === "SKU_NOT_FOUND" || code === "WHITE_BACKGROUND_NOT_FOUND") { + if ( + code === "SKU_NOT_FOUND" || + code === "SPU_NOT_FOUND" || + code === "NO_SKUS" || + code === "WHITE_BACKGROUND_NOT_FOUND" + ) { return 404; } if (code === "INVALID_API_KEY") { @@ -169,6 +187,7 @@ export async function GET(request: NextRequest) { const parsed = querySchema.parse({ keyword: request.nextUrl.searchParams.get("keyword"), source: request.nextUrl.searchParams.get("source"), + queryType: request.nextUrl.searchParams.get("queryType"), }); const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); @@ -187,9 +206,10 @@ export async function GET(request: NextRequest) { } const normalizedKeyword = parsed.keyword.trim().toUpperCase(); + const queryType = parsed.queryType === "spu" ? "spu" : "sku"; const upstreamUrl = `${normalizeBaseUrl( config.baseUrl - )}/standard-model-white-background?sku=${encodeURIComponent(normalizedKeyword)}`; + )}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`; const upstreamResponse = await fetch(upstreamUrl, { method: "GET", headers: { @@ -216,6 +236,45 @@ export async function GET(request: NextRequest) { ); } + if (queryType === "spu") { + const skuOptions = Array.isArray(upstreamResult.data.skuList) + ? upstreamResult.data.skuList + .map((item) => { + const skuName = String(item?.skuName || "").trim(); + if (!skuName) return null; + return { + skuName, + skuId: String(item?.skuId || "").trim() || null, + hasWhiteBackground: item?.hasWhiteBackground === true, + whiteBackgroundCount: Number.isFinite(Number(item?.whiteBackgroundCount)) + ? Math.max(0, Math.min(999, Math.round(Number(item?.whiteBackgroundCount)))) + : 0, + previewImageUrl: String(item?.previewImage?.downloadUrl || "").trim() || null, + previewFileName: String(item?.previewImage?.fileName || "").trim() || null, + }; + }) + .filter(Boolean) + : []; + + return NextResponse.json({ + success: true, + data: { + query: normalizedKeyword, + queryType: "spu", + matchedSku: "", + matchedSpu: String(upstreamResult.data.spu || normalizedKeyword).trim() || normalizedKeyword, + materialId: String(upstreamResult.data.materialId || "").trim() || null, + productModel: String(upstreamResult.data.spu || normalizedKeyword).trim() || null, + zipUrl: null, + fixedCount: 0, + otherCount: 0, + totalCount: skuOptions.length, + images: [], + skuOptions, + }, + }); + } + const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword; const productModel = String(upstreamResult.data.productModel || "").trim() || null; const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed) @@ -291,7 +350,9 @@ export async function GET(request: NextRequest) { success: true, data: { query: normalizedKeyword, + queryType: "sku", matchedSku, + matchedSpu: productModel, materialId: String(upstreamResult.data.materialId || "").trim() || null, productModel, zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null, @@ -299,6 +360,7 @@ export async function GET(request: NextRequest) { otherCount: importedImages.filter((item) => item.group === "other").length, totalCount: importedImages.length, images: importedImages, + skuOptions: [], }, }); } catch (error) { diff --git a/app/api/agent-workflow/reference-search/route.ts b/app/api/agent-workflow/reference-search/route.ts new file mode 100644 index 000000000..c6d487823 --- /dev/null +++ b/app/api/agent-workflow/reference-search/route.ts @@ -0,0 +1,275 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getSystemSetting } from "@/lib/db/queries"; +import { + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + normalizeAgentWorkflowMaterialApiConfig, +} from "@/lib/agents/workflow-material-config"; +import type { + AgentDetailReplacementReferenceItem, + AgentModeReferenceCandidate, + AgentWorkflowMaterialQueryType, +} from "@/types/agent"; + +export const maxDuration = 180; + +const querySchema = z.object({ + keyword: z.string().trim().min(1).max(64), + queryType: z.enum(["sku", "spu"]).default("spu"), + target: z.enum(["detail", "sku"]), +}); + +type ExternalTaskDetailCandidate = { + materialId?: string; + taskNo?: string; + productModel?: string; + categoryName?: string; + shopName?: string; + sceneName?: string; + uploadedAt?: string; + isFullSet?: boolean; + downloadUrls?: { + main?: string[]; + sku?: string[]; + detail?: string[]; + all?: string[]; + }; +}; + +type ExternalTaskDetailResponse = { + success?: boolean; + data?: { + resourceType?: string; + spu?: string | null; + sku?: string | null; + categoryName?: string | null; + shopName?: string | null; + candidateCount?: number; + candidates?: ExternalTaskDetailCandidate[]; + }; + error?: { + code?: string; + message?: string; + }; +}; + +function normalizeBaseUrl(value: string) { + return String(value || "").trim().replace(/\/+$/, ""); +} + +function normalizeUrlList(value: unknown) { + if (!Array.isArray(value)) return [] as string[]; + return Array.from( + new Set( + value + .map((item) => String(item || "").trim()) + .filter((item) => /^https?:\/\//i.test(item)) + ) + ).slice(0, 64); +} + +function mapUpstreamErrorStatus(code: string) { + if ( + code === "SKU_NOT_FOUND" || + code === "SPU_NOT_FOUND" || + code === "TASK_DETAIL_NOT_FOUND" || + code === "FULL_SET_NOT_FOUND" + ) { + return 404; + } + if (code === "INVALID_API_KEY") { + return 500; + } + if (code === "SKU_NOT_UNIQUE") { + return 409; + } + return 502; +} + +function normalizeCandidate( + value: ExternalTaskDetailCandidate, + index: number +): AgentModeReferenceCandidate { + const uploadedAt = String(value.uploadedAt || "").trim() || null; + const taskNo = String(value.taskNo || "").trim() || null; + const materialId = String(value.materialId || "").trim() || null; + const sceneName = String(value.sceneName || "").trim() || null; + const id = taskNo || materialId || `candidate-${index + 1}`; + + return { + id, + materialId, + taskNo, + productModel: String(value.productModel || "").trim() || null, + categoryName: String(value.categoryName || "").trim() || null, + shopName: String(value.shopName || "").trim() || null, + sceneName, + uploadedAt, + isFullSet: value.isFullSet !== false, + mainImageUrls: normalizeUrlList(value.downloadUrls?.main), + skuImageUrls: normalizeUrlList(value.downloadUrls?.sku), + detailImageUrls: normalizeUrlList(value.downloadUrls?.detail), + allImageUrls: normalizeUrlList(value.downloadUrls?.all), + }; +} + +function getCandidateTargetUrls( + candidate: AgentModeReferenceCandidate, + target: "detail" | "sku" +) { + return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls; +} + +function buildReferenceItems(params: { + candidate: AgentModeReferenceCandidate; + target: "detail" | "sku"; +}): AgentDetailReplacementReferenceItem[] { + const urls = getCandidateTargetUrls(params.candidate, params.target); + const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图"; + const prefix = + params.candidate.sceneName || + params.candidate.taskNo || + params.candidate.productModel || + "候选整套"; + + return urls.map((url, index) => ({ + id: `${params.candidate.id}-${params.target}-${index + 1}`, + label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`, + imageUrl: url, + candidateId: params.candidate.id, + sortOrder: index, + })); +} + +function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[]) { + return candidates.slice().sort((left, right) => { + const leftTime = left.uploadedAt ? new Date(left.uploadedAt).getTime() : 0; + const rightTime = right.uploadedAt ? new Date(right.uploadedAt).getTime() : 0; + return rightTime - leftTime; + }); +} + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const parsed = querySchema.parse({ + keyword: request.nextUrl.searchParams.get("keyword"), + queryType: request.nextUrl.searchParams.get("queryType") || "spu", + target: request.nextUrl.searchParams.get("target"), + }); + + const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); + const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); + if (!config.enabled) { + return NextResponse.json( + { error: "素材搜索功能未开启,请先在系统设置中启用" }, + { status: 400 } + ); + } + if (!config.apiKey) { + return NextResponse.json( + { error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, + { status: 500 } + ); + } + + const normalizedKeyword = parsed.keyword.trim().toUpperCase(); + const queryType = parsed.queryType as AgentWorkflowMaterialQueryType; + const upstreamUrl = `${normalizeBaseUrl( + config.baseUrl + )}/standard-model-white-background?resourceType=task-detail&${queryType}=${encodeURIComponent( + normalizedKeyword + )}`; + + const upstreamResponse = await fetch(upstreamUrl, { + method: "GET", + headers: { + "X-API-Key": config.apiKey, + }, + cache: "no-store", + }); + const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse; + + if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) { + const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim(); + const errorMessage = + String(upstreamResult.error?.message || "").trim() || + `参考图搜索失败(${upstreamResponse.status})`; + return NextResponse.json( + { + success: false, + error: { + code: errorCode, + message: errorMessage, + }, + }, + { status: mapUpstreamErrorStatus(errorCode) } + ); + } + + const normalizedCandidates = sortCandidatesByUploadedAtDesc( + (upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index)) + ).filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0); + + if (normalizedCandidates.length === 0) { + return NextResponse.json( + { + success: false, + error: { + code: + parsed.target === "detail" + ? "DETAIL_REFERENCE_NOT_FOUND" + : "SKU_REFERENCE_NOT_FOUND", + message: + parsed.target === "detail" + ? "当前整套素材中没有可用的详情参考图" + : "当前整套素材中没有可用的 SKU 参考图", + }, + }, + { status: 404 } + ); + } + + const selectedCandidate = normalizedCandidates[0]; + const references = buildReferenceItems({ + candidate: selectedCandidate, + target: parsed.target, + }); + + return NextResponse.json({ + success: true, + data: { + query: normalizedKeyword, + queryType, + target: parsed.target, + matchedSpu: String(upstreamResult.data.spu || "").trim() || null, + matchedSku: String(upstreamResult.data.sku || "").trim() || null, + categoryName: String(upstreamResult.data.categoryName || "").trim() || null, + shopName: String(upstreamResult.data.shopName || "").trim() || null, + candidateCount: normalizedCandidates.length, + candidates: normalizedCandidates, + selectedCandidateId: selectedCandidate.id, + references, + }, + }); + } catch (error) { + console.error("[Agent Workflow Reference Search] Error:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "搜索参数无效", details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "参考图搜索失败,请稍后重试" }, + { status: 500 } + ); + } +} diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 421b46b3a..c505510e3 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -90,7 +90,12 @@ import { buildAgentWorkflowComposerContext, getAgentWorkflowProgress, } from "@/lib/agents/workflow-composer"; +import { + createEmptyAgentModeWorkspaceState, + normalizeAgentModeWorkspaceState, +} from "@/lib/agents/mode-workspace-state"; import type { + AgentModeWorkspaceState, AgentWorkflowComposerContext, AgentWorkflowMaterialSelections, AgentWorkflowPersistedState, @@ -120,6 +125,23 @@ const DOCKED_PANEL_INSET = 0; const FLOATING_UI_GAP = 16; const FLOATING_BOTTOM_INSET = 20; +function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") { + const normalized = String(projectId || "").trim().toLowerCase().replace(/-/g, ""); + if (!/^[0-9a-f]{32}$/.test(normalized)) { + return projectId; + } + + const scopeSuffix = scope === "agent-mode" ? "a93e17a60001" : "a93e17a60000"; + const scoped = `${normalized.slice(0, 20)}${scopeSuffix}`; + return [ + scoped.slice(0, 8), + scoped.slice(8, 12), + scoped.slice(12, 16), + scoped.slice(16, 20), + scoped.slice(20, 32), + ].join("-"); +} + export default function CanvasEditorPage({ params }: PageProps) { // 使用 useParams 确保客户端导航时能正确获取更新的 id const routeParams = useParams(); @@ -187,9 +209,13 @@ export default function CanvasEditorPage({ params }: PageProps) { useState(null); const [selectedAgentPresetId, setSelectedAgentPresetId] = useState(null); const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow"); + const [agentModeState, setAgentModeState] = useState( + () => createEmptyAgentModeWorkspaceState() + ); const [workflowHydrationSource, setWorkflowHydrationSource] = useState< "pending" | "project" | "local" | "empty" >("pending"); + const agentModeChatId = useMemo(() => buildScopedCanvasChatId(id, "agent-mode"), [id]); const { data: siteSettingsData } = useSWR<{ siteName?: string; @@ -293,6 +319,7 @@ export default function CanvasEditorPage({ params }: PageProps) { setWorkflowSelections({}); setSelectedBrandModel(null); setSelectedAgentPresetId(null); + setAgentModeState(createEmptyAgentModeWorkspaceState()); // 🆕 先清空当前画布状态,确保切换时不会显示旧数据 useCanvasStore.getState().clearCanvas(); @@ -348,6 +375,9 @@ export default function CanvasEditorPage({ params }: PageProps) { setCurrentAgentStage( persistedWorkflow.currentStage === "mode" ? "mode" : "workflow" ); + setAgentModeState( + normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState) + ); setWorkflowHydrationSource("project"); } else { setWorkflowHydrationSource("empty"); @@ -407,6 +437,7 @@ export default function CanvasEditorPage({ params }: PageProps) { selectedBrandModel, selectedAgentPresetId: selectedAgentPresetId || null, currentStage: currentAgentStage, + agentModeState, }; return { @@ -418,6 +449,7 @@ export default function CanvasEditorPage({ params }: PageProps) { getCanvasState, selectedAgentPresetId, selectedBrandModel, + agentModeState, workflowInputs, workflowMaterials, workflowSelections, @@ -461,6 +493,7 @@ export default function CanvasEditorPage({ params }: PageProps) { ); setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null); setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow"); + setAgentModeState(normalizeAgentModeWorkspaceState(parsed.agentModeState)); } catch (error) { console.warn("[Canvas] Failed to hydrate workflow state from localStorage:", error); } finally { @@ -481,6 +514,7 @@ export default function CanvasEditorPage({ params }: PageProps) { selectedBrandModel, selectedAgentPresetId, currentAgentStage, + agentModeState, }) ); } catch (error) { @@ -495,6 +529,7 @@ export default function CanvasEditorPage({ params }: PageProps) { selectedBrandModel, selectedAgentPresetId, currentAgentStage, + agentModeState, ]); useEffect(() => { @@ -1030,6 +1065,7 @@ export default function CanvasEditorPage({ params }: PageProps) { setWorkflowSelections({}); setSelectedBrandModel(null); setSelectedAgentPresetId(null); + setAgentModeState(createEmptyAgentModeWorkspaceState()); setCurrentAgentStage("workflow"); }} /> @@ -1039,15 +1075,24 @@ export default function CanvasEditorPage({ params }: PageProps) { // 第二步:智能体选择 + 生图工作台 return ( setCurrentAgentStage("workflow")} + onOpenCanvasEditor={() => { + setCurrentAgentStage("mode"); + updateCanvasSearchParams({ + mode: null, + }); + }} onClearPreset={() => setSelectedAgentPresetId(null)} // 嵌入的智能体选择面板 presetPanel={ diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 5b61b6c4c..c456e5267 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -5,6 +5,7 @@ import { DefaultChatTransport } from "ai"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import useSWR from "swr"; import { motion, AnimatePresence } from "framer-motion"; +import JSZip from "jszip"; import { ArrowUp, Download, @@ -33,14 +34,24 @@ import { useCanvasStore } from "@/store/canvas"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { extractImageUrlsFromText } from "@/lib/image-url"; +import { + getAgentRequiredUploadCount, + normalizeAgentPresetModeConfig, +} from "@/lib/agents/preset-mode-config"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import type { + AgentModeReferenceCandidate, + AgentModeWorkspaceState, AgentPreset, AgentWorkflowComposerContext, + AgentWorkflowMaterialQueryType, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; +import { + createEmptyAgentModeWorkspaceState, +} from "@/lib/agents/mode-workspace-state"; interface AgentGenerationWorkspaceDarkV2Props { chatId: string; @@ -56,6 +67,10 @@ interface AgentGenerationWorkspaceDarkV2Props { siteName?: string | null; siteLogo?: string | null; siteLogoDark?: string | null; + syncGeneratedResultsToCanvas?: boolean; + agentModeState?: AgentModeWorkspaceState | null; + onAgentModeStateChange?: (next: AgentModeWorkspaceState) => void; + onOpenCanvasEditor?: () => void; } interface PickedAttachment extends Attachment {} @@ -66,6 +81,27 @@ interface GeneratedImageGroup { text: string; } +interface AgentModeReferenceSearchResponse { + success?: boolean; + data?: { + query: string; + queryType: AgentWorkflowMaterialQueryType; + target: "detail" | "sku"; + matchedSpu?: string | null; + matchedSku?: string | null; + categoryName?: string | null; + shopName?: string | null; + candidateCount: number; + candidates: AgentModeReferenceCandidate[]; + selectedCandidateId?: string | null; + references: AgentModeWorkspaceState["detailReplacement"]["references"]; + }; + error?: { + code?: string; + message?: string; + }; +} + type ToolStepKey = | "preset" | "brand-model" @@ -138,6 +174,58 @@ function getImageDimensions(url: string): Promise<{ }); } +function buildReferenceCardsFromCandidate(params: { + candidate: AgentModeReferenceCandidate | null | undefined; + target: "detail" | "sku"; + productCount?: number; +}) { + const candidate = params.candidate; + if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"]; + + const urls = + params.target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; + const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图"; + const prefix = + candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"; + + return urls.map((url, index) => ({ + id: `${candidate.id}-${params.target}-${index + 1}`, + label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`, + imageUrl: url, + candidateId: candidate.id, + sortOrder: index, + assignedProductIndex: + params.target === "sku" && (params.productCount || 0) > 0 + ? Math.min(index, Math.max((params.productCount || 1) - 1, 0)) + : null, + })); +} + +function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: "detail" | "sku") { + const imageCount = + target === "detail" + ? candidate.detailImageUrls.length + : candidate.skuImageUrls.length; + const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null; + const timeText = + uploadedAt && !Number.isNaN(uploadedAt.getTime()) + ? uploadedAt.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : null; + const parts = [ + candidate.sceneName || null, + candidate.taskNo || null, + imageCount > 0 ? `${imageCount}张` : null, + timeText, + ].filter(Boolean); + + return parts.join(" · "); +} + export function AgentGenerationWorkspaceDarkV2({ chatId, projectId, @@ -152,6 +240,10 @@ export function AgentGenerationWorkspaceDarkV2({ siteName, siteLogo, siteLogoDark, + syncGeneratedResultsToCanvas = true, + agentModeState = null, + onAgentModeStateChange, + onOpenCanvasEditor, }: AgentGenerationWorkspaceDarkV2Props) { const rawChatId = useMemo( () => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId), @@ -159,17 +251,38 @@ export function AgentGenerationWorkspaceDarkV2({ ); const addNode = useCanvasStore((state) => state.addNode); const nodes = useCanvasStore((state) => state.nodes); + const selectNodes = useCanvasStore((state) => state.selectNodes); + const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction); const fileInputRefs = useRef>([]); const hiddenPromptRef = useRef(null); const generatedImageKeysRef = useRef>(new Set()); const lastSyncedBrandModelUrlRef = useRef(null); + const [localAgentModeState, setLocalAgentModeState] = useState( + () => createEmptyAgentModeWorkspaceState() + ); const [input, setInput] = useState(""); const [attachments, setAttachments] = useState>([]); + const [activeAssignmentSlotIndex, setActiveAssignmentSlotIndex] = useState(null); const [slotUploading, setSlotUploading] = useState(null); const [selectedModelId, setSelectedModelId] = useState("auto"); const [selectedResultUrl, setSelectedResultUrl] = useState(null); const [fullscreenPreview, setFullscreenPreview] = useState(null); + const [batchDownloadState, setBatchDownloadState] = useState(null); + const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{ + isLoading: boolean; + lastQuery: string | null; + }>({ + isLoading: false, + lastQuery: null, + }); + const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{ + isLoading: boolean; + lastQuery: string | null; + }>({ + isLoading: false, + lastQuery: null, + }); const [imageParams, setImageParams] = useState({ aspectRatio: "auto", imageSize: "2K", @@ -206,19 +319,109 @@ export function AgentGenerationWorkspaceDarkV2({ const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]); const referenceImages = workflowContext?.referenceImages || []; + const presetModeConfig = useMemo( + () => normalizeAgentPresetModeConfig(selectedAgentPreset?.defaultParams), + [selectedAgentPreset?.defaultParams] + ); + const isSceneGenerationPreset = + selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "scene_generation"; + const isDetailReplacementPreset = + selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"; + const isSkuReplacementPreset = + selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "sku_replacement"; + const isReplacementPreset = isDetailReplacementPreset || isSkuReplacementPreset; + const productSlotCount = + selectedAgentPreset?.type === "image" + ? isSceneGenerationPreset + ? presetModeConfig.scene.productImageSlotCount + : isDetailReplacementPreset + ? presetModeConfig.detail.productImageSlotCount + : isSkuReplacementPreset + ? presetModeConfig.sku.productImageSlotCount + : 2 + : 2; + const modelSlotEnabled = + selectedAgentPreset?.type === "image" + ? isSceneGenerationPreset + ? presetModeConfig.scene.includeModelSlot + : isDetailReplacementPreset + ? presetModeConfig.detail.includeModelSlot + : isSkuReplacementPreset + ? presetModeConfig.sku.includeModelSlot + : true + : false; + const modelSlotIndex = modelSlotEnabled ? productSlotCount : -1; const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text"); const agentUploadSlots = useMemo( - () => - hasAgentUploadSlots - ? [ - { slotIndex: 0, label: "产品图1" }, - { slotIndex: 1, label: "产品图2" }, - { slotIndex: 2, label: "模特图" }, - ] - : [], - [hasAgentUploadSlots] + () => { + if (!hasAgentUploadSlots) return []; + const nextSlots = Array.from({ length: productSlotCount }, (_, index) => ({ + slotIndex: index, + label: `产品图${index + 1}`, + })); + if (modelSlotEnabled) { + nextSlots.push({ + slotIndex: modelSlotIndex, + label: "模特图", + }); + } + return nextSlots; + }, + [hasAgentUploadSlots, modelSlotEnabled, modelSlotIndex, productSlotCount] ); - const requiredUploadCount = hasAgentUploadSlots ? 3 : 0; + const requiredUploadCount = hasAgentUploadSlots + ? getAgentRequiredUploadCount({ + presetType: selectedAgentPreset?.type || "image", + defaultParams: selectedAgentPreset?.defaultParams || null, + fallback: modelSlotEnabled ? 3 : productSlotCount, + }) + : 0; + const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride; + const selectedProductAttachments = attachments.filter((item, index) => + modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item) + ); + const selectedModelAttachment = + modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null; + const slotSourceLibrary = useMemo( + () => + referenceImages.map((image, index) => ({ + id: `${image.source}-${image.id || index}`, + source: image.source, + sourceLabel: image.sourceLabel, + label: image.label, + name: image.name, + url: image.url, + assetId: image.assetId || undefined, + mimeType: image.mimeType || undefined, + })), + [referenceImages] + ); + const activeAssignmentIsModelSlot = + activeAssignmentSlotIndex !== null && + modelSlotIndex >= 0 && + activeAssignmentSlotIndex === modelSlotIndex; + const assignableSourceLibrary = useMemo(() => { + if (!slotSourceLibrary.length) return []; + if (activeAssignmentIsModelSlot) { + const brandModelOnly = slotSourceLibrary.filter((item) => item.source === "brandModel"); + return brandModelOnly; + } + return slotSourceLibrary.filter((item) => item.source !== "brandModel"); + }, [activeAssignmentIsModelSlot, slotSourceLibrary]); + const resolvedAgentModeState = agentModeState || localAgentModeState; + const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState; + const detailReplacementState = resolvedAgentModeState.detailReplacement; + const detailReplacementQueryInput = detailReplacementState.query; + const detailReplacementQueryType = detailReplacementState.queryType; + const detailReplacementCandidates = detailReplacementState.candidates; + const detailReplacementSelectedCandidateId = detailReplacementState.selectedCandidateId; + const detailReplacementReferenceCards = detailReplacementState.references; + const skuReplacementState = resolvedAgentModeState.skuReplacement; + const skuReplacementQueryInput = skuReplacementState.query; + const skuReplacementQueryType = skuReplacementState.queryType; + const skuReplacementCandidates = skuReplacementState.candidates; + const skuReplacementSelectedCandidateId = skuReplacementState.selectedCandidateId; + const skuReplacementReferenceCards = skuReplacementState.references; const selectableModels = useMemo(() => { const imageModels = (modelsData?.models || []).filter( @@ -238,7 +441,7 @@ export function AgentGenerationWorkspaceDarkV2({ [selectableModels, selectedModelId] ); const [activeToolStep, setActiveToolStep] = useState("preset"); - const hasBrandModelStep = Boolean(brandModelPanel); + const hasBrandModelStep = Boolean(brandModelPanel) && modelSlotEnabled; const hasMaterialStep = Boolean(materialSelectionPanel); const { messages, setMessages, sendMessage, status } = useChat({ @@ -250,6 +453,38 @@ export function AgentGenerationWorkspaceDarkV2({ api: "/api/chat", fetch: fetchWithErrorHandlers, prepareSendMessagesRequest(request) { + const replacementModePayload = + selectedAgentPreset?.type === "image" && + (isDetailReplacementPreset || isSkuReplacementPreset) + ? { + modeType: isDetailReplacementPreset + ? ("detail_replacement" as const) + : ("sku_replacement" as const), + query: isDetailReplacementPreset + ? detailReplacementQueryInput.trim() + : skuReplacementQueryInput.trim(), + queryType: isDetailReplacementPreset + ? detailReplacementQueryType + : skuReplacementQueryType, + selectedCandidateId: isDetailReplacementPreset + ? detailReplacementSelectedCandidateId || null + : skuReplacementSelectedCandidateId || null, + references: ( + isDetailReplacementPreset + ? detailReplacementReferenceCards + : skuReplacementReferenceCards + ) + .filter((item) => Boolean(String(item.imageUrl || "").trim())) + .map((item) => ({ + id: item.id, + label: item.label, + imageUrl: String(item.imageUrl || "").trim(), + candidateId: item.candidateId || null, + sortOrder: item.sortOrder ?? 0, + assignedProductIndex: item.assignedProductIndex ?? null, + })), + } + : undefined; return { body: { id: rawChatId, @@ -259,6 +494,7 @@ export function AgentGenerationWorkspaceDarkV2({ selectedChatModel: selectedModelId, selectedVisibilityType: "private", imageParams, + agentModePayload: replacementModePayload, }, }; }, @@ -299,23 +535,34 @@ export function AgentGenerationWorkspaceDarkV2({ ? { url: image.url, name: image.name || image.label, contentType: image.mimeType || "image/png", assetId: image.assetId || undefined } : null; - nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]); - nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]); - nextSlots[2] = toAttachment(brandModelImages[0]); + 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 < 3; index += 1) { + for (let index = 0; index < agentUploadSlots.length; index += 1) { if (!result[index] && nextSlots[index]) { result[index] = nextSlots[index] as PickedAttachment; } } return result; }); - }, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]); + }, [ + agentUploadSlots.length, + hasAgentUploadSlots, + modelSlotEnabled, + modelSlotIndex, + productSlotCount, + referenceImages, + ]); useEffect(() => { if (!hasAgentUploadSlots) { @@ -334,16 +581,20 @@ export function AgentGenerationWorkspaceDarkV2({ setAttachments((current) => { const slots: Array = Array.from( - { length: Math.max(agentUploadSlots.length, 3) }, + { length: Math.max(agentUploadSlots.length, Math.max(modelSlotIndex + 1, 1)) }, (_, index) => current[index] ?? null ); - const currentSlot = slots[2] ?? null; + const currentSlot = modelSlotIndex >= 0 ? slots[modelSlotIndex] ?? null : null; const previousSyncedUrl = lastSyncedBrandModelUrlRef.current; - if (nextAttachment) { - slots[2] = nextAttachment; - } else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) { - slots[2] = null; + if ( + modelSlotIndex >= 0 && + nextAttachment && + (!currentSlot || currentSlot.url === previousSyncedUrl) + ) { + slots[modelSlotIndex] = nextAttachment; + } else if (modelSlotIndex >= 0 && previousSyncedUrl && currentSlot?.url === previousSyncedUrl) { + slots[modelSlotIndex] = null; } return slots; @@ -353,6 +604,7 @@ export function AgentGenerationWorkspaceDarkV2({ }, [ agentUploadSlots.length, hasAgentUploadSlots, + modelSlotIndex, selectedBrandModel?.assetId, selectedBrandModel?.brandName, selectedBrandModel?.displayName, @@ -384,7 +636,30 @@ export function AgentGenerationWorkspaceDarkV2({ () => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))), [generatedGroups] ); - const latestGeneratedGroup = generatedGroups[generatedGroups.length - 1] || null; + const latestGeneratedGroup = useMemo(() => { + const lastUserIndex = [...messages] + .map((message) => message.role) + .lastIndexOf("user"); + if (lastUserIndex >= 0) { + const latestAssistantAfterLastUser = messages + .slice(lastUserIndex + 1) + .filter((message) => message.role === "assistant") + .map((message) => generatedGroups.find((group) => group.messageId === message.id) || null) + .filter((group): group is GeneratedImageGroup => Boolean(group)) + .at(-1); + if (latestAssistantAfterLastUser) { + return latestAssistantAfterLastUser; + } + const hasAssistantAfterLastUser = messages + .slice(lastUserIndex + 1) + .some((message) => message.role === "assistant"); + if (hasAssistantAfterLastUser) { + return null; + } + } + return generatedGroups[generatedGroups.length - 1] || null; + }, [generatedGroups, messages]); + const latestReplacementResultUrls = latestGeneratedGroup?.imageUrls || []; const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null; const isGenerating = status === "streaming" || status === "submitted"; const attachmentCount = attachments.filter(Boolean).length; @@ -486,6 +761,19 @@ export function AgentGenerationWorkspaceDarkV2({ } }, [latestGeneratedGroup?.imageUrls, selectedResultUrl]); + useEffect(() => { + if (agentUploadSlots.length === 0) { + setActiveAssignmentSlotIndex(null); + return; + } + setActiveAssignmentSlotIndex((current) => { + if (current !== null && current >= 0 && current < agentUploadSlots.length) { + return current; + } + return 0; + }); + }, [agentUploadSlots.length]); + useEffect(() => { if (!selectedAgentPresetId) { setActiveToolStep("preset"); @@ -494,11 +782,21 @@ export function AgentGenerationWorkspaceDarkV2({ setActiveToolStep((current) => { if (current !== "preset") return current; + if (isDetailReplacementPreset || isSkuReplacementPreset) { + if (hasMaterialStep) return "materials"; + return "slots"; + } if (hasBrandModelStep) return "brand-model"; if (hasMaterialStep) return "materials"; return "slots"; }); - }, [hasBrandModelStep, hasMaterialStep, selectedAgentPresetId]); + }, [ + hasBrandModelStep, + hasMaterialStep, + isDetailReplacementPreset, + isSkuReplacementPreset, + selectedAgentPresetId, + ]); useEffect(() => { if (!toolSteps.some((step) => step.key === activeToolStep)) { @@ -507,6 +805,10 @@ export function AgentGenerationWorkspaceDarkV2({ }, [activeToolStep, toolSteps]); useEffect(() => { + if (!syncGeneratedResultsToCanvas) { + return; + } + const latestUrls = latestGeneratedGroup?.imageUrls || []; if (latestUrls.length === 0) return; @@ -529,7 +831,7 @@ export function AgentGenerationWorkspaceDarkV2({ src: url, status: "completed", title: "生成结果", - prompt: latestGeneratedGroup.text || "生成结果", + prompt: latestGeneratedGroup?.text || "生成结果", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, @@ -541,7 +843,7 @@ export function AgentGenerationWorkspaceDarkV2({ console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error); } }); - }, [addNode, latestGeneratedGroup, nodes.length]); + }, [addNode, latestGeneratedGroup, nodes.length, syncGeneratedResultsToCanvas]); const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => { const slotCount = Math.max(agentUploadSlots.length, 1); @@ -589,12 +891,124 @@ export function AgentGenerationWorkspaceDarkV2({ }, [agentUploadSlots, updateAttachmentAtSlot] ); + const handleAssignSourceToSlot = useCallback( + (slotIndex: number, source: (typeof slotSourceLibrary)[number]) => { + updateAttachmentAtSlot(slotIndex, { + url: source.url, + name: source.name || source.label || `槽位${slotIndex + 1}`, + contentType: source.mimeType || "image/png", + assetId: source.assetId, + }); + toast.success(`${source.label || source.name || "已选素材"} 已填入 ${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}`); + }, + [agentUploadSlots, slotSourceLibrary, updateAttachmentAtSlot] + ); + const handleBatchDownloadImages = useCallback(async (params: { + imageUrls: string[]; + zipName: string; + scope: "scene" | "detail" | "sku"; + }) => { + const normalizedUrls = Array.from( + new Set( + (params.imageUrls || []) + .map((item) => String(item || "").trim()) + .filter((item) => /^https?:\/\//i.test(item)) + ) + ); + if (normalizedUrls.length === 0) { + toast.error("当前没有可下载的结果图"); + return; + } + + setBatchDownloadState(params.scope); + try { + const zip = new JSZip(); + let successCount = 0; + + for (const [index, imageUrl] of normalizedUrls.entries()) { + try { + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`download failed: ${response.status}`); + } + const blob = await response.blob(); + const fallbackName = `${params.zipName}-${String(index + 1).padStart(2, "0")}.png`; + zip.file(buildDownloadFilename(imageUrl) || fallbackName, blob); + successCount += 1; + } catch (error) { + console.warn("[AgentGenerationWorkspaceDarkV2] Batch download item failed:", { + imageUrl, + error, + }); + } + } + + if (successCount === 0) { + throw new Error("没有成功下载到任何图片"); + } + + const blob = await zip.generateAsync({ type: "blob" }); + const link = document.createElement("a"); + link.href = window.URL.createObjectURL(blob); + link.download = `${params.zipName}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(link.href); + toast.success(`已打包下载 ${successCount} 张图片`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "批量下载失败"); + } finally { + setBatchDownloadState(null); + } + }, []); + const handleSendResultToCanvasTextEdit = useCallback((params: { + imageUrl: string; + title: string; + }) => { + const rawUrl = String(params.imageUrl || "").trim(); + if (!rawUrl) { + toast.error("结果图地址无效"); + return; + } + + const previewTitle = String(params.title || "").trim() || "待改字结果"; + const nodeIndex = nodes.length; + const nodeId = addNode({ + type: "image", + position: { + x: 160 + (nodeIndex % 3) * 340, + y: 140 + Math.floor(nodeIndex / 3) * 340, + }, + size: { width: 320, height: 320 }, + data: { + src: rawUrl, + status: "completed", + title: previewTitle, + prompt: previewTitle, + }, + connections: [], + }); + + selectNodes([nodeId]); + requestImageNodeAction(nodeId, "text-edit"); + onOpenCanvasEditor?.(); + toast.success("已送入画布,可继续改字"); + }, [addNode, nodes.length, onOpenCanvasEditor, requestImageNodeAction, selectNodes]); const handleSend = useCallback(() => { if (!selectedAgentPresetId) { toast.error("请先选择生图模式"); return; } + if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) { + toast.error("请先查询并选择详情参考图"); + return; + } + if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { + toast.error("请先查询并选择 SKU 参考图"); + return; + } const uploadedAttachmentCount = attachments.filter(Boolean).length; if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) { toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`); @@ -624,7 +1038,22 @@ export function AgentGenerationWorkspaceDarkV2({ : null; sendMessage({ role: "user", parts }); setInput(""); - }, [attachments, hasAgentUploadSlots, input, requiredUploadCount, selectedAgentPreset, selectedAgentPresetId, sendMessage, workflowContext?.promptText, workflowContext?.summaryText]); + }, [ + attachments, + detailReplacementReferenceCards.length, + hasAgentUploadSlots, + input, + isDetailReplacementPreset, + isReplacementPreset, + isSkuReplacementPreset, + requiredUploadCount, + selectedAgentPreset, + selectedAgentPresetId, + sendMessage, + skuReplacementReferenceCards.length, + workflowContext?.promptText, + workflowContext?.summaryText, + ]); const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { const raw = String(imageUrl || "").trim(); @@ -672,6 +1101,233 @@ export function AgentGenerationWorkspaceDarkV2({ }, [toolSteps] ); + const updateDetailReplacementState = useCallback( + ( + updater: + | Partial + | ((current: AgentModeWorkspaceState["detailReplacement"]) => AgentModeWorkspaceState["detailReplacement"]) + ) => { + const current = resolvedAgentModeState.detailReplacement; + const nextDetailReplacement = + typeof updater === "function" ? updater(current) : { ...current, ...updater }; + emitAgentModeState({ + ...resolvedAgentModeState, + detailReplacement: nextDetailReplacement, + }); + }, + [emitAgentModeState, resolvedAgentModeState] + ); + const updateSkuReplacementState = useCallback( + ( + updater: + | Partial + | ((current: AgentModeWorkspaceState["skuReplacement"]) => AgentModeWorkspaceState["skuReplacement"]) + ) => { + const current = resolvedAgentModeState.skuReplacement; + const nextSkuReplacement = + typeof updater === "function" ? updater(current) : { ...current, ...updater }; + emitAgentModeState({ + ...resolvedAgentModeState, + skuReplacement: nextSkuReplacement, + }); + }, + [emitAgentModeState, resolvedAgentModeState] + ); + const handleDetailReplacementSearch = useCallback(async () => { + const normalized = detailReplacementQueryInput.trim().toUpperCase(); + if (!normalized) { + toast.error(`请输入${detailReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询详情图`); + return; + } + + setDetailReplacementSearchState({ + isLoading: true, + lastQuery: normalized, + }); + + try { + const response = await fetch( + `/api/agent-workflow/reference-search?keyword=${encodeURIComponent( + normalized + )}&queryType=${detailReplacementQueryType}&target=detail`, + { cache: "no-store" } + ); + const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; + + if (!response.ok || !result?.success || !result?.data) { + throw new Error(result?.error?.message || "详情参考图查询失败"); + } + + updateDetailReplacementState({ + query: normalized, + queryType: result.data.queryType || detailReplacementQueryType, + candidates: result.data.candidates || [], + selectedCandidateId: + result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null, + references: result.data.references || [], + }); + setDetailReplacementSearchState({ + isLoading: false, + lastQuery: normalized, + }); + toast.success(`已载入 ${result.data.references?.length || 0} 张详情参考图`); + } catch (error) { + setDetailReplacementSearchState({ + isLoading: false, + lastQuery: normalized, + }); + toast.error(error instanceof Error ? error.message : "详情参考图查询失败"); + } + }, [detailReplacementQueryInput, detailReplacementQueryType, updateDetailReplacementState]); + const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => { + updateDetailReplacementState((current) => { + const nextCandidate = + current.candidates.find((candidate) => candidate.id === candidateId) || null; + return { + ...current, + selectedCandidateId: candidateId, + references: buildReferenceCardsFromCandidate({ + candidate: nextCandidate, + target: "detail", + productCount: selectedProductAttachments.length, + }), + }; + }); + }, [selectedProductAttachments.length, updateDetailReplacementState]); + const handleRemoveDetailReferenceCard = useCallback((cardId: string) => { + updateDetailReplacementState((current) => ({ + ...current, + references: current.references.filter((card) => card.id !== cardId), + })); + }, [updateDetailReplacementState]); + const handleClearDetailReferenceCards = useCallback(() => { + updateDetailReplacementState((current) => ({ + ...current, + candidates: [], + selectedCandidateId: null, + references: [], + })); + }, [updateDetailReplacementState]); + const handleSkuReplacementSearch = useCallback(async () => { + const normalized = skuReplacementQueryInput.trim().toUpperCase(); + if (!normalized) { + toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询 SKU 参考图`); + return; + } + + setSkuReplacementSearchState({ + isLoading: true, + lastQuery: normalized, + }); + + try { + const response = await fetch( + `/api/agent-workflow/reference-search?keyword=${encodeURIComponent( + normalized + )}&queryType=${skuReplacementQueryType}&target=sku`, + { cache: "no-store" } + ); + const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; + + if (!response.ok || !result?.success || !result?.data) { + throw new Error(result?.error?.message || "SKU 参考图查询失败"); + } + + updateSkuReplacementState({ + query: normalized, + queryType: result.data.queryType || skuReplacementQueryType, + candidates: result.data.candidates || [], + selectedCandidateId: + result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null, + references: + (result.data.references || []).map((item, index) => ({ + ...item, + assignedProductIndex: + selectedProductAttachments.length > 0 + ? Math.min(index, Math.max(selectedProductAttachments.length - 1, 0)) + : null, + })) || [], + }); + setSkuReplacementSearchState({ + isLoading: false, + lastQuery: normalized, + }); + toast.success(`已载入 ${result.data.references?.length || 0} 张 SKU 参考图`); + } catch (error) { + setSkuReplacementSearchState({ + isLoading: false, + lastQuery: normalized, + }); + toast.error(error instanceof Error ? error.message : "SKU 参考图查询失败"); + } + }, [ + selectedProductAttachments.length, + skuReplacementQueryInput, + skuReplacementQueryType, + updateSkuReplacementState, + ]); + const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => { + updateSkuReplacementState((current) => { + const nextCandidate = + current.candidates.find((candidate) => candidate.id === candidateId) || null; + return { + ...current, + selectedCandidateId: candidateId, + references: buildReferenceCardsFromCandidate({ + candidate: nextCandidate, + target: "sku", + productCount: selectedProductAttachments.length, + }), + }; + }); + }, [selectedProductAttachments.length, updateSkuReplacementState]); + const handleAssignSkuReferenceProduct = useCallback((cardId: string, productIndex: number) => { + updateSkuReplacementState((current) => ({ + ...current, + references: current.references.map((card) => + card.id === cardId + ? { + ...card, + assignedProductIndex: productIndex, + } + : card + ), + })); + }, [updateSkuReplacementState]); + useEffect(() => { + if (!isSkuReplacementPreset) return; + updateSkuReplacementState((current) => ({ + ...current, + references: current.references.map((card, index) => ({ + ...card, + assignedProductIndex: + selectedProductAttachments.length > 0 + ? Math.min( + card.assignedProductIndex ?? index, + Math.max(selectedProductAttachments.length - 1, 0) + ) + : null, + })), + })); + }, [ + isSkuReplacementPreset, + selectedProductAttachments.length, + 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]); return (
@@ -787,6 +1443,13 @@ export function AgentGenerationWorkspaceDarkV2({
Current Module

{activeToolStepMeta.label}

{activeToolStepMeta.description}

+ {isReplacementPreset ? ( +

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

+ ) : null}
{selectedAgentPreset ? ( @@ -857,70 +1520,164 @@ export function AgentGenerationWorkspaceDarkV2({ 图片槽位 {agentUploadSlots.length > 0 ? ( -
- {agentUploadSlots.map((slot, index) => ( - fileInputRefs.current[index]?.click()} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - className={cn( - "group relative aspect-square overflow-hidden rounded-[24px] transition-all", - attachments[index] - ? "ring-2 ring-emerald-400/45" - : "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35" - )} - > - { - fileInputRefs.current[index] = el; - }} - type="file" - accept="image/*" - className="hidden" - onChange={(e) => handleSlotFileChange(index, e)} - /> - {attachments[index] ? ( - <> - {attachments[index]!.name -
- {slot.label} -
- - - ) : ( -
- {slotUploading === index ? ( - - ) : ( - <> - - {slot.label} - +
+
+ {agentUploadSlots.map((slot, index) => { + const isActiveAssignmentSlot = activeAssignmentSlotIndex === index; + return ( + + { + fileInputRefs.current[index] = el; + }} + type="file" + accept="image/*" + className="hidden" + onChange={(e) => handleSlotFileChange(index, e)} + /> + {attachments[index] ? ( + <> + {attachments[index]!.name +
+ {slot.label} +
+ + + ) : ( +
+ {slotUploading === index ? ( + + ) : ( + <> + + {slot.label} + + )} +
+ )} + +
+ + +
+
+ ); + })} +
+ + {slotSourceLibrary.length > 0 ? ( +
+
+
+
从第一步已选素材填入槽位
+
+ 先点击上方槽位的“设为目标”,再点击下方素材图,将它放入对应槽位。 +
+
+
+ 当前目标: + {activeAssignmentSlotIndex !== null + ? agentUploadSlots[activeAssignmentSlotIndex]?.label || "未选择" + : "未选择"} +
+
+ + {assignableSourceLibrary.length > 0 ? ( +
+ {assignableSourceLibrary.map((source) => { + const assignedToActiveSlot = + activeAssignmentSlotIndex !== null && + attachments[activeAssignmentSlotIndex]?.url === source.url; + return ( + + ); + })} +
+ ) : ( +
+ {activeAssignmentIsModelSlot + ? "当前模特槽位还没有可分配的品牌模特图,请先在“品牌模特”步骤里选择。" + : "当前产品槽位还没有来自第一步的已选商品图,请先到“SKU / SPU 选图”里完成选择。"}
)} - - ))} +
+ ) : null}
) : (
- 先选择一个生图模式,系统会自动展示产品图1、产品图2和模特图槽位。 + 先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
)}
@@ -948,18 +1705,35 @@ export function AgentGenerationWorkspaceDarkV2({ 生成参数
- + {isReplacementPreset ? ( +
+ {isDetailReplacementPreset + ? "详情替换会按当前参考图顺序逐张执行,结果将在下方结果区一一回显。" + : "SKU替换会按当前参考图顺序执行一对一替换,结果将在下方结果区一一回显。"} +
+ ) : null} + + {shouldAllowModelOverride ? ( + + ) : ( +
+ 当前模板已锁定模型: + + {selectedAgentPreset?.defaultModel?.displayName || currentModel?.displayName || "未配置"} + +
+ )} - 生成中... + {isReplacementPreset ? "批量替换中..." : "生成中..."} ) : ( <> - 开始生成 + {isReplacementPreset ? "开始批量替换" : "开始生成"} )} @@ -1046,108 +1820,820 @@ export function AgentGenerationWorkspaceDarkV2({
结果预览
- 生成完成后会同步回到画布,并在这里保留预览与切换。 + {isDetailReplacementPreset + ? "详情替换结果会按参考图顺序纵向展示,并保持在当前工作台预览,不会自动回写到无限画布。" + : isSkuReplacementPreset + ? "SKU替换结果会按参考图顺序一对一展示,并保持在当前工作台预览,不会自动回写到无限画布。" + : "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
- {allGeneratedImages.length > 0 ? ( -
- 共 {allGeneratedImages.length} 张 -
- ) : null} +
+ {isDetailReplacementPreset && latestReplacementResultUrls.length > 0 ? ( + + ) : null} + {isSkuReplacementPreset && latestReplacementResultUrls.length > 0 ? ( + + ) : null} + {!isReplacementPreset && allGeneratedImages.length > 1 ? ( + + ) : null} + + {isDetailReplacementPreset ? ( +
+ 预估 {detailReplacementReferenceCards.length} 屏 +
+ ) : isSkuReplacementPreset ? ( +
+ 预估 {skuReplacementReferenceCards.length} 组 +
+ ) : allGeneratedImages.length > 0 ? ( +
+ 共 {allGeneratedImages.length} 张 +
+ ) : null} +
- - {isGenerating ? ( - - - - ) : displayedResultUrl ? ( - - 生成结果 setFullscreenPreview(displayedResultUrl)} - /> - - - ) : ( - -
- + {isDetailReplacementPreset ? ( +
+
+
+
+
输入图
+
+ 选中的白底图会作为详情替换的输入图组 +
+
+
+ {selectedProductAttachments.length} 张 +
-

- {selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"} -

-

- {selectedAgentPresetId - ? "顶部流程线配置完成后,切到“生成参数”即可开始生成" - : "请先从顶部流程线选择一个生图模式"} -

- - )} - - {allGeneratedImages.length > 0 ? ( - -
- {allGeneratedImages.map((url) => ( -
+ +
+
+
+
详情参考图
+
+ 支持按 SPU 或 SKU 查询任务-详情整套候选,并切换某一套的详情参考图。 +
+
+
+ {detailReplacementReferenceCards.length} 张 +
+
+ +
+ {(["spu", "sku"] as const).map((queryType) => ( + + ))} +
+ +
+