From f0086e26daa9882312baec5c9d6f7dcc831312e0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Apr 2026 12:06:10 +0800 Subject: [PATCH] fix: preserve secondary sku scene references --- app/(chat)/api/chat/route.ts | 58 +++++- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 169 ++++++++++++++++-- lib/agents/scene-image-role-prompt.ts | 98 ++++++---- lib/agents/workflow-composer.ts | 17 ++ 4 files changed, 292 insertions(+), 50 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 704e83f6a..b6934c92f 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -2850,6 +2850,48 @@ function extractCurrentMessageImageParts( })); } +function getSceneUploadedImageBreakdown(params: { + uploadedImageParts: AgentPresetMessageImagePart[]; + fallbackPrimaryCount: number; + fallbackIncludeModelImage: boolean; +}) { + const normalizedNames = params.uploadedImageParts.map((part) => String(part.name || "").trim()); + const primaryCountFromNames = normalizedNames.filter((name) => name.startsWith("主SKU参考图")).length; + const secondaryCountFromNames = normalizedNames.filter((name) => name.startsWith("搭配SKU参考图")).length; + const hasModelFromNames = normalizedNames.some((name) => name.startsWith("品牌模特图")); + const hasNamedBreakdown = + primaryCountFromNames > 0 || secondaryCountFromNames > 0 || hasModelFromNames; + + if (hasNamedBreakdown) { + return { + primaryProductImageCount: primaryCountFromNames, + bundleProductImageCount: secondaryCountFromNames, + includeModelImage: hasModelFromNames, + }; + } + + const uploadedImageCount = params.uploadedImageParts.length; + const includeModelImage = + params.fallbackIncludeModelImage && uploadedImageCount > params.fallbackPrimaryCount; + const bundleProductImageCount = Math.max( + 0, + uploadedImageCount - params.fallbackPrimaryCount - (includeModelImage ? 1 : 0) + ); + const primaryProductImageCount = Math.max( + 0, + Math.min( + params.fallbackPrimaryCount, + uploadedImageCount - bundleProductImageCount - (includeModelImage ? 1 : 0) + ) + ); + + return { + primaryProductImageCount, + bundleProductImageCount, + includeModelImage, + }; +} + function normalizeAgentSupplementPrompt(text: string, presetName?: string | null) { const normalized = String(text || "").trim(); if (!normalized) return ""; @@ -7257,10 +7299,15 @@ export async function POST(request: Request) { [...sharedUploadedAssetIds, ...(sceneSlot?.referenceAssetIds || [])].filter(Boolean), ), ); + const sceneUploadedImageBreakdown = getSceneUploadedImageBreakdown({ + uploadedImageParts, + fallbackPrimaryCount: sceneProductSlotCount, + fallbackIncludeModelImage: sceneModelSlotEnabled, + }); const sceneImageRolePrompt = buildAgentSceneImageRolePrompt({ - uploadedImageCount: sharedUploadedImageUrls.length, - productImageCount: sceneProductSlotCount, - includeModelImage: sceneModelSlotEnabled, + primaryProductImageCount: sceneUploadedImageBreakdown.primaryProductImageCount, + bundleProductImageCount: sceneUploadedImageBreakdown.bundleProductImageCount, + includeModelImage: sceneUploadedImageBreakdown.includeModelImage, }); const shouldAppendSceneSlotPrompt = !hiddenPromptText; const rawSceneSlotPrompt = shouldAppendSceneSlotPrompt @@ -7295,10 +7342,11 @@ export async function POST(request: Request) { agentExecutionSnapshotId: presetSnapshot.id, includesWorkflowPrompt: Boolean(hiddenPromptText), includesUserSupplementPrompt: Boolean(sanitizedUserSupplementPrompt), - includesModelImage: sceneModelSlotEnabled, + includesModelImage: sceneUploadedImageBreakdown.includeModelImage, shouldAppendSceneSlotPrompt, includesSceneSlotPrompt: Boolean(dedupedSceneSlotPrompt), - productImageCount: sceneProductSlotCount, + productImageCount: sceneUploadedImageBreakdown.primaryProductImageCount, + bundleImageCount: sceneUploadedImageBreakdown.bundleProductImageCount, uploadedImageCount: sharedUploadedImageUrls.length, hasProductIdentityLock: sceneImageRolePrompt.includes("【产品锁定约束】"), hasModelIdentityLock: sceneImageRolePrompt.includes("【模特锁定约束】"), diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index b5a9e7bc1..1ebd99daa 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -392,6 +392,26 @@ function getAttachmentIdentityKey( return url ? `url:${url}` : null; } +function toWorkflowReferenceAttachment(params: { + url?: string | null; + name?: string | null; + label?: string | null; + mimeType?: string | null; + assetId?: string | null; +}): PickedAttachment | null { + const url = String(params.url || "").trim(); + if (!isValidChatHttpUrl(url)) { + return null; + } + + return { + url, + name: normalizeChatAttachmentName(params.name || params.label || "参考图"), + contentType: normalizeChatMediaType(params.mimeType || "image/png"), + assetId: isValidChatUuid(params.assetId) ? params.assetId || undefined : undefined, + }; +} + type ToolStepKey = | "preset" | "brand-model" @@ -1246,6 +1266,97 @@ export function AgentGenerationWorkspaceDarkV2({ ); const selectedModelAttachment = modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null; + const scenePrimaryReferenceCount = useMemo( + () => referenceImages.filter((image) => image.source === "primary").length, + [referenceImages] + ); + const sceneSecondaryReferenceAttachments = useMemo(() => { + const seenKeys = new Set(); + return referenceImages + .filter((image) => image.source === "secondary") + .map((image) => + toWorkflowReferenceAttachment({ + url: image.url, + name: image.name, + label: image.label, + mimeType: image.mimeType, + assetId: image.assetId || null, + }) + ) + .filter((attachment): attachment is PickedAttachment => { + if (!attachment) return false; + const key = getAttachmentIdentityKey(attachment); + if (key && seenKeys.has(key)) { + return false; + } + if (key) { + seenKeys.add(key); + } + return true; + }); + }, [referenceImages]); + const sceneSecondaryReferenceCount = sceneSecondaryReferenceAttachments.length; + const sceneMessageAttachments = useMemo(() => { + if (!isSceneGenerationPreset) { + return attachments + .map((attachment) => sanitizeChatAttachment(attachment)) + .filter((attachment): attachment is PickedAttachment => Boolean(attachment)); + } + + const ordered: PickedAttachment[] = []; + const seenKeys = new Set(); + const pushUniqueAttachment = ( + attachment: + | PickedAttachment + | { + url?: string | null; + name?: string | null; + contentType?: string | null; + assetId?: string | null; + } + | null + | undefined + ) => { + const sanitized = sanitizeChatAttachment(attachment as PickedAttachment | null | undefined); + if (!sanitized) return; + const key = getAttachmentIdentityKey(sanitized); + if (key && seenKeys.has(key)) { + return; + } + if (key) { + seenKeys.add(key); + } + ordered.push(sanitized); + }; + + const primaryVisibleCount = Math.min( + Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0), + selectedProductAttachments.length + ); + selectedProductAttachments.slice(0, primaryVisibleCount).forEach((attachment) => { + pushUniqueAttachment(attachment); + }); + selectedProductAttachments.slice(primaryVisibleCount).forEach((attachment) => { + pushUniqueAttachment(attachment); + }); + sceneSecondaryReferenceAttachments.forEach((attachment) => { + pushUniqueAttachment(attachment); + }); + pushUniqueAttachment(selectedModelAttachment); + + attachments.forEach((attachment) => { + pushUniqueAttachment(attachment); + }); + + return ordered; + }, [ + attachments, + isSceneGenerationPreset, + scenePrimaryReferenceCount, + sceneSecondaryReferenceAttachments, + selectedModelAttachment, + selectedProductAttachments, + ]); const filledAttachmentCount = attachments.filter(Boolean).length; const missingAttachmentCount = Math.max(requiredUploadCount - filledAttachmentCount, 0); const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`; @@ -1257,20 +1368,25 @@ export function AgentGenerationWorkspaceDarkV2({ const attachmentSummaryReady = hasAgentUploadSlots && (requiredUploadCount === 0 || filledAttachmentCount >= requiredUploadCount); const sceneImageRolePromptPreview = useMemo(() => { - if (!isSceneGenerationPreset || filledAttachmentCount <= 0) { + const sceneHasModelReference = Boolean(selectedModelAttachment); + if ( + !isSceneGenerationPreset || + scenePrimaryReferenceCount + sceneSecondaryReferenceCount + (sceneHasModelReference ? 1 : 0) <= 0 + ) { return ""; } return buildAgentSceneImageRolePrompt({ - uploadedImageCount: filledAttachmentCount, - productImageCount: productSlotCount, - includeModelImage: modelSlotEnabled, + primaryProductImageCount: Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0), + bundleProductImageCount: sceneSecondaryReferenceCount, + includeModelImage: sceneHasModelReference, }); }, [ - filledAttachmentCount, isSceneGenerationPreset, - modelSlotEnabled, - productSlotCount, + scenePrimaryReferenceCount, + sceneSecondaryReferenceCount, + selectedModelAttachment, + selectedProductAttachments.length, ]); const detailReplacementQueryInput = detailReplacementState.query; const detailReplacementCandidates = detailReplacementState.candidates; @@ -2589,19 +2705,43 @@ export function AgentGenerationWorkspaceDarkV2({ return; } const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = []; - const sanitizedAttachments = attachments - .map((attachment) => sanitizeChatAttachment(attachment)) - .filter((attachment): attachment is PickedAttachment => Boolean(attachment)); + const sanitizedAttachments = sceneMessageAttachments; if (hasAgentUploadSlots && sanitizedAttachments.length < requiredUploadCount) { toast.error(`当前模式需要上传 ${requiredUploadCount} 张有效图片`); return; } - sanitizedAttachments.forEach((attachment) => { + const primaryAttachmentCountForMessage = Math.min( + scenePrimaryReferenceCount, + sanitizedAttachments.length + ); + const secondaryAttachmentCountForMessage = Math.min( + sceneSecondaryReferenceCount, + Math.max( + sanitizedAttachments.length - + primaryAttachmentCountForMessage - + (selectedModelAttachment ? 1 : 0), + 0 + ) + ); + sanitizedAttachments.forEach((attachment, index) => { if (!attachment) return; + let attachmentName = normalizeChatAttachmentName(attachment.name); + if (isSceneGenerationPreset) { + if (index < primaryAttachmentCountForMessage) { + attachmentName = `主SKU参考图 ${index + 1}`; + } else if (index < primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage) { + attachmentName = `搭配SKU参考图 ${index - primaryAttachmentCountForMessage + 1}`; + } else if ( + selectedModelAttachment && + index === primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage + ) { + attachmentName = "品牌模特图"; + } + } parts.push({ type: "file", mediaType: normalizeChatMediaType(attachment.contentType), - name: normalizeChatAttachmentName(attachment.name), + name: attachmentName, url: attachment.url, assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, }); @@ -2679,7 +2819,12 @@ export function AgentGenerationWorkspaceDarkV2({ skuReplacementReferenceCards.length, userMessageCount, getLatestWorkflowContext, + isSceneGenerationPreset, workflowContext, + sceneMessageAttachments, + scenePrimaryReferenceCount, + sceneSecondaryReferenceCount, + selectedModelAttachment, ]); const handleStopGeneration = useCallback(() => { detailGenerationCancelRef.current = true; diff --git a/lib/agents/scene-image-role-prompt.ts b/lib/agents/scene-image-role-prompt.ts index e783851af..68804f090 100644 --- a/lib/agents/scene-image-role-prompt.ts +++ b/lib/agents/scene-image-role-prompt.ts @@ -1,29 +1,47 @@ export function buildAgentSceneImageRolePrompt(params: { - uploadedImageCount: number; - productImageCount: number; + primaryProductImageCount: number; + bundleProductImageCount: number; includeModelImage: boolean; }) { - if (params.uploadedImageCount <= 0) { + const primaryProductImageCount = Math.max( + 0, + Math.floor(Number(params.primaryProductImageCount) || 0) + ); + const bundleProductImageCount = Math.max( + 0, + Math.floor(Number(params.bundleProductImageCount) || 0) + ); + const uploadedImageCount = + primaryProductImageCount + + bundleProductImageCount + + (params.includeModelImage ? 1 : 0); + if (uploadedImageCount <= 0) { return ""; } const lines = ["【输入图片角色说明】"]; - const resolvedProductCount = Math.max( - 1, - Math.min(params.productImageCount, params.uploadedImageCount) - ); - const hasModelImage = - params.includeModelImage && params.uploadedImageCount > resolvedProductCount; + const hasPrimaryProductImage = primaryProductImageCount > 0; + const hasBundleProductImage = bundleProductImageCount > 0; + const hasModelImage = params.includeModelImage; + const bundleStartIndex = primaryProductImageCount; + const modelImageIndex = primaryProductImageCount + bundleProductImageCount; - for (let index = 0; index < params.uploadedImageCount; index += 1) { - if (index < resolvedProductCount) { + for (let index = 0; index < uploadedImageCount; index += 1) { + if (index < primaryProductImageCount) { lines.push( - `图片${index + 1}:产品白底参考图,用于锁定产品的外观、比例、材质、颜色、结构和细节。` + `图片${index + 1}:主SKU白底参考图,用于锁定主产品的外观、比例、材质、颜色、结构和细节。` ); continue; } - if (hasModelImage && index === resolvedProductCount) { + if (index >= bundleStartIndex && index < bundleStartIndex + bundleProductImageCount) { + lines.push( + `图片${index + 1}:搭配SKU白底参考图,用于锁定搭配产品本身的外观与结构。它是独立的第二产品,只能用于组合展示、场景搭配或补充关联销售,不能替代主SKU,也不能和主SKU融合成一件产品。` + ); + continue; + } + + if (hasModelImage && index === modelImageIndex) { lines.push( `图片${index + 1}:固定模特图。若画面需要人物,必须复用这张图中的同一模特(脸型、发型、体型、肤色、穿搭风格保持一致),不得替换成其他人物。` ); @@ -35,28 +53,42 @@ export function buildAgentSceneImageRolePrompt(params: { ); } - lines.push( - `必须以图片1${resolvedProductCount > 1 ? `到图片${resolvedProductCount}` : ""}中的产品信息为最高优先级,严格保持同一产品。` - ); - lines.push( - "【产品锁定约束】生成结果中的产品必须与产品白底参考图保持同一产品身份,禁止改成另一款产品。" - ); - lines.push( - "严禁改变产品的主体结构、外轮廓、长宽高比例、层板数量与位置、抽屉数量与位置、柜门/开孔/隔断布局、弧度造型、柜腿样式、把手/五金位置。" - ); - lines.push( - "严禁改变产品主色、木纹方向、材质类型、表面纹理、明暗层次和细节做工;不得把深色木纹改成浅色、白色、金属色或其他颜色体系。" - ); - lines.push( - "如果场景风格与产品外观产生冲突,必须优先保留产品本体不变,只允许调整周边场景、软装、灯光、背景和道具,不允许为了适配风格而修改产品。" - ); - lines.push( - "产品白底图是产品身份锁定图,不是灵感参考图。输出时必须让人一眼看出是同一个产品,只能换场景,不能换产品本体。" - ); - lines.push("不要把多张输入图拼贴成多个主体,也不要把所有输入图元素强行同时融入结果。"); + if (hasPrimaryProductImage) { + lines.push( + `必须以图片1${ + primaryProductImageCount > 1 ? `到图片${primaryProductImageCount}` : "" + }中的主SKU信息为最高优先级,严格保持主产品身份不变。` + ); + lines.push( + "【产品锁定约束】生成结果中的主SKU必须与主SKU白底参考图保持同一产品身份,禁止改成另一款产品。" + ); + lines.push( + "严禁改变主SKU的主体结构、外轮廓、长宽高比例、层板数量与位置、抽屉数量与位置、柜门/开孔/隔断布局、弧度造型、柜腿样式、把手/五金位置。" + ); + lines.push( + "严禁改变主SKU主色、木纹方向、材质类型、表面纹理、明暗层次和细节做工;不得把深色木纹改成浅色、白色、金属色或其他颜色体系。" + ); + lines.push( + "如果场景风格与主SKU外观产生冲突,必须优先保留主产品本体不变,只允许调整周边场景、软装、灯光、背景和道具,不允许为了适配风格而修改主产品。" + ); + lines.push( + "主SKU白底图是主产品身份锁定图,不是灵感参考图。输出时必须让人一眼看出是同一个主产品,只能换场景,不能换主产品本体。" + ); + } + if (hasBundleProductImage) { + lines.push( + "【搭配产品约束】搭配SKU是独立的第二产品。需要搭配展示时,结果中必须同时出现主SKU与搭配SKU,清楚表现组合关系与使用场景。" + ); + lines.push( + "严禁把搭配SKU当成主SKU的另一视角、局部细节或替代品;也严禁把主SKU与搭配SKU融合、拼接、嫁接成单一产品。" + ); + lines.push( + "如果当前场景不适合展示搭配SKU,可以弱化其占比,但不得因此篡改、替换或覆盖主SKU。" + ); + } lines.push( hasModelImage - ? `如果当前场景有人物展示需求,只允许使用图片${resolvedProductCount + 1}里的模特;如果不需要人物,则不要强行加入人物。` + ? `如果当前场景有人物展示需求,只允许使用图片${modelImageIndex + 1}里的模特;如果不需要人物,则不要强行加入人物。` : "当前没有固定模特图,不要凭空生成强人物主体。" ); if (hasModelImage) { diff --git a/lib/agents/workflow-composer.ts b/lib/agents/workflow-composer.ts index 7fa6270f1..98ff5ae83 100644 --- a/lib/agents/workflow-composer.ts +++ b/lib/agents/workflow-composer.ts @@ -83,6 +83,19 @@ function buildGoodsInfoSummaryLine(params: { return `${params.label}:${parts.join(" / ")}`; } +function buildBundleUsagePromptLine(params: { + referenceImages: AgentWorkflowReferenceImage[]; +}) { + const primaryCount = params.referenceImages.filter((item) => item.source === "primary").length; + const secondaryCount = params.referenceImages.filter((item) => item.source === "secondary").length; + + if (primaryCount <= 0 || secondaryCount <= 0) { + return ""; + } + + return "搭配SKU使用规则:搭配SKU是独立的第二产品,不是主SKU的另一角度或替代图。需要做搭配展示时,必须同场景同时展示主SKU与搭配SKU(例如桌子+椅子),保持两者各自的外观、比例和结构,不得把两件产品融合成一件,也不得用搭配SKU替换主SKU。"; +} + function normalizeWorkflowText(value: string) { return String(value || "") .replace(/\s+/g, " ") @@ -323,6 +336,10 @@ export function buildAgentWorkflowComposerContext(params: { }` ); } + const bundleUsagePrompt = buildBundleUsagePromptLine({ referenceImages }); + if (bundleUsagePrompt) { + promptLines.push(bundleUsagePrompt); + } promptLines.push( ...buildGroupedSelectionLines({ selections,