From 0b9d41e7e277b3fafbbba669e2c16be6cb378118 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 2 Jun 2026 15:39:39 +0800 Subject: [PATCH] fix: force detail replacement generation --- .../api/chat/_lib/detail-replacement.ts | 241 ++++++++++++++---- app/(chat)/api/chat/route.ts | 115 ++++----- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 5 +- 3 files changed, 239 insertions(+), 122 deletions(-) diff --git a/app/(chat)/api/chat/_lib/detail-replacement.ts b/app/(chat)/api/chat/_lib/detail-replacement.ts index 08c667820..b88049875 100644 --- a/app/(chat)/api/chat/_lib/detail-replacement.ts +++ b/app/(chat)/api/chat/_lib/detail-replacement.ts @@ -239,6 +239,10 @@ export type ReplacementScenePlan = { export type ReplacementExecutionPlan = ReplacementReferenceDecision & { scenePlan: ReplacementScenePlan; }; +type ReplacementReferenceDecisionCore = Omit< + ReplacementReferenceDecision, + "detectionModelName" | "detectionSource" +>; export type ReplacementScenePlanningRuntime = | { @@ -261,6 +265,100 @@ export function normalizeDetailReplacementStrategy( return value === "product_only" ? "product_only" : "scene_replace"; } +function isStitchedDetailReferenceLabel(label: string) { + return /^拼合切片\s*\d+/i.test(String(label || "").trim()); +} + +function shouldForceDetailReferenceGeneration(params: { + modeType?: "detail_replacement" | "sku_replacement"; +}) { + return params.modeType === "detail_replacement"; +} + +function ensureDetailReferenceGenerates( + decision: T, + scenePlan: ReplacementScenePlan, + params: { + modeType?: "detail_replacement" | "sku_replacement"; + referenceLabel: string; + replacementStrategy?: AgentDetailReplacementStrategy; + }, +): { decision: T; scenePlan: ReplacementScenePlan } { + if (!shouldForceDetailReferenceGeneration(params)) { + return { decision, scenePlan }; + } + + if (decision.shouldReplace && scenePlan.generationRoute !== "direct_passthrough") { + return { decision, scenePlan }; + } + + const isProductOnly = params.replacementStrategy === "product_only"; + const isStitchedReference = isStitchedDetailReferenceLabel(params.referenceLabel); + const forcedRoute: DetailReplacementGenerationRoute = isProductOnly + ? "layout_preserve_replace" + : scenePlan.peoplePolicy !== "none" + ? "people_relation_locked" + : scenePlan.copyImageCoupled || scenePlan.textPolicy === "lock" + ? "copy_locked_regenerate" + : "layout_preserve_replace"; + const forcedReason = isStitchedReference + ? "拼合切片未勾选不处理,继续执行详情替换" + : "详情替换未勾选不处理,继续执行必要替换"; + const forcedGenerationPrompt = + "保留当前详情图的版式、文案、图标、构图和留白;先寻找可替换产品/产品展示区,没有产品则检查人物/模特展示,没有人物则根据上传图外观与参考图材质描述处理板材、材质、原材料、背景或视觉图片区,不允许整屏直出。"; + + return { + decision: { + ...decision, + shouldReplace: true, + containsFurnitureProduct: true, + reason: forcedReason, + }, + scenePlan: { + ...scenePlan, + generationRoute: + scenePlan.generationRoute === "direct_passthrough" + ? forcedRoute + : scenePlan.generationRoute, + promptPackPolicy: + scenePlan.generationRoute === "direct_passthrough" + ? "forbid" + : scenePlan.promptPackPolicy, + textPolicy: scenePlan.textPolicy === "free" ? "preserve" : scenePlan.textPolicy, + routeReason: + scenePlan.generationRoute === "direct_passthrough" || !decision.shouldReplace + ? "详情替换只允许选择处理策略,不允许模型直出跳过;按产品、模特、材质或视觉区继续处理。" + : scenePlan.routeReason, + sceneSummary: + scenePlan.sceneSummary || + "优先处理产品/产品展示区;没有产品则处理人物/模特展示;没有人物则依据板材、材质、原材料、背景或视觉区做必要替换。", + generationPrompt: + scenePlan.generationRoute === "direct_passthrough" || !decision.shouldReplace + ? forcedGenerationPrompt + : scenePlan.generationPrompt || forcedGenerationPrompt, + }, + }; +} + +function buildDefaultProductImageSelection(productImageCount: number) { + const safeProductImageCount = Math.max(0, Math.floor(productImageCount)); + return { + selectedProductImageIndexes: + safeProductImageCount > 0 + ? Array.from( + { length: Math.min(safeProductImageCount, 2) }, + (_, index) => index + 1, + ) + : [], + productSelectionReason: + safeProductImageCount > 1 + ? "默认优先使用前两张上传图作为产品、材质或细节参考,其余上传图只作补充理解,不强制同时融入。" + : safeProductImageCount === 1 + ? "默认使用当前唯一上传图作为产品、材质或细节参考。" + : "本轮未提供专门商品图;按模特图、模板补充图、上传图外观、参考图材质文案和当前参考底板判断可处理区域。", + }; +} + export function truncateReplacementAnalysisText(value: unknown, maxLength = 240) { return String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength); } @@ -538,6 +636,7 @@ export function parseReplacementScenePlan( 1200, ); + const productImageSelection = buildDefaultProductImageSelection(params.productImageCount); const scenePlan: ReplacementScenePlan = { generationRoute, promptPackPolicy, @@ -579,14 +678,8 @@ export function parseReplacementScenePlan( 220, ), generationPrompt, - selectedProductImageIndexes: Array.from( - { length: Math.min(Math.max(params.productImageCount, 1), 2) }, - (_, index) => index + 1, - ), - productSelectionReason: - params.productImageCount > 1 - ? "默认优先使用前两张产品图作为主视角与补充细节,其余角度不强制同时融入。" - : "默认使用当前唯一产品图作为主体参考。", + selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes, + productSelectionReason: productImageSelection.productSelectionReason, analysisModelName: null, analysisSource: "heuristic", }; @@ -619,6 +712,7 @@ export function buildHeuristicReplacementScenePlan(params: { replacementStrategy?: AgentDetailReplacementStrategy; }): ReplacementScenePlan { const isProductOnly = params.replacementStrategy === "product_only"; + const productImageSelection = buildDefaultProductImageSelection(params.productImageCount); const generationRoute = params.shouldReplace ? isProductOnly ? "layout_preserve_replace" @@ -636,14 +730,8 @@ export function buildHeuristicReplacementScenePlan(params: { ? "兜底按仅替换产品执行,不使用词包。" : "兜底未检测到强约束,允许使用词包生成场景。" : params.reason, - selectedProductImageIndexes: Array.from( - { length: Math.min(Math.max(params.productImageCount, 1), 2) }, - (_, index) => index + 1, - ), - productSelectionReason: - params.productImageCount > 1 - ? "默认优先使用前两张产品图作为主视角与补充细节,其余角度不强制同时融入。" - : "默认使用当前唯一产品图作为主体参考。", + selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes, + productSelectionReason: productImageSelection.productSelectionReason, useModelImage: false, modelImageReason: params.hasModelImage ? isProductOnly @@ -929,18 +1017,19 @@ export async function analyzeReplacementReferenceImage(params: { ? [ "你是电商详情图产品主体替换判定器。", "请判断这张详情参考图里,是否存在清晰、主要、适合被新产品替换的商品主体、产品展示区或产品照片区域。", - "你的任务不是重建整张场景,也不是替换文字排版;只判断是否有明确的产品替换目标。", + "详情替换里 shouldReplace 固定为 true;你的任务不是决定是否跳过,而是说明优先处理哪个产品/材质/视觉区域。", "文字、标题、卖点标签、按钮、UI图标、分割线、品牌标识、留白和背景装饰不属于替换对象。", - "只有当图片几乎是纯文字页、参数表、品牌介绍页、没有明确产品展示区时,shouldReplace 才能为 false。", + "即使没有清晰产品主体,也要继续寻找产品展示区、材质/板材/原材料视觉区或可局部处理区域,不允许直接沿用参考图。", '只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}', ].join("\n") : isDetailReplacement ? [ "你是电商详情参考图背景替换判定器。", "请判断这张图片里,是否存在适合被重建/替换的背景图、产品场景图、商品主体图或大面积视觉图片区。", - "这不是只找家具主体:只要存在照片、插画、场景背景、产品展示图区、人物展示图区,都可以 shouldReplace=true。", + "详情替换里 shouldReplace 固定为 true;你的任务不是决定是否跳过,而是说明应优先处理产品、人物/模特、材质/板材/原材料、背景或视觉区。", + "这不是只找家具主体:只要存在照片、插画、场景背景、产品展示图区、人物展示图区、材质说明区,都必须继续处理。", "必须保留的文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版不属于替换对象。", - "只有当图片几乎是纯文字页、参数表、品牌介绍页、无明显背景图/场景图区/商品图片区时,shouldReplace 才能为 false。", + "如果没有产品,检查是否需要模特/人物;如果没有人物,则根据上传图外观和参考图材质描述处理板材、材质、原材料、背景或视觉图片区;不允许直接沿用参考图。", '只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}', ].join("\n") : [ @@ -1024,6 +1113,16 @@ export async function analyzeReplacementReferenceImage(params: { chatApiLogger.warn("[Chat API] Fallback Qwen3-VL reference analysis failed:", error); } + if (isDetailReplacement) { + return { + shouldReplace: true, + containsFurnitureProduct: true, + reason: "详情替换未勾选不处理,继续执行必要替换", + detectionModelName: null, + detectionSource: "safe_passthrough", + }; + } + return { shouldReplace: false, containsFurnitureProduct: false, @@ -1141,6 +1240,12 @@ export async function analyzeReplacementExecutionPlan(params: { params.modelImageUrl && !isProductOnlyDetailReplacement ? "如果当前参考图存在人物展示需求,后续生图必须尽量复用提供模特图中的同一人物身份,不要明显换脸、换发型、换体型。" : "", + params.modelImageUrl && !isProductOnlyDetailReplacement + ? "如果当前参考图是多人/群体/互动关系图,即使只提供 1 张模特图,也必须保留参考图的人数、相对位置和互动关系;提供的模特图作为主人物身份/气质参考,其余人物按参考图生成相近人群,不要把多人压成单人。" + : "", + isDetailReplacement + ? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。" + : "", "这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。", isProductOnlyDetailReplacement ? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。" @@ -1149,13 +1254,13 @@ export async function analyzeReplacementExecutionPlan(params: { : "这张参考图不是要求原样保留的场景照片。原图里的沙发、床、柜体、墙面、地面、窗帘、灯具、摆件、桌面道具、背景家具和环境光氛围,都可以在后续生图阶段按前置工作流风格重建。", "请完成以下任务:", isProductOnlyDetailReplacement - ? "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被新产品替换的商品主体、产品照片区域或产品展示区。纯文字/纯参数/纯品牌介绍且没有产品展示区时才为 false。" + ? "1. shouldReplace 固定为 true:详情替换只允许规划处理范围,不允许判定直接沿用。若没有清晰产品主体,也要继续寻找产品展示区、材质/板材/原材料视觉区或可局部处理区域。" : isDetailReplacement - ? "1. 判断 shouldReplace:当前参考图里是否存在适合被替换/重建的背景图、产品场景图、商品主体图、人物展示图或大面积视觉图片区。纯文字/纯参数/纯品牌介绍且没有视觉图片区时才为 false。" + ? "1. shouldReplace 固定为 true:除非用户已经在前置流程人工勾选不处理(此处不会进入分析),你不能返回 false。若没有产品,检查人物/模特;没有人物,检查板材/材质/原材料、背景图或其它视觉区。" : "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被替换的家具商品主体。", "2. 判断 useModelImage,并说明原因。", isDetailReplacement - ? "3. 判断 generationRoute:prompt_pack_scene=通用场景图且可吃词包;layout_preserve_replace=锁定版式/白底/产品说明,只做有限替换;copy_locked_regenerate=文案与画面强绑定,必须锁文案;people_relation_locked=多人/模特/互动关系图,必须锁人物关系;direct_passthrough=纯文字/白底说明/无可替换视觉区,直接沿用。" + ? "3. 判断 generationRoute:prompt_pack_scene=通用场景图且可吃词包;layout_preserve_replace=锁定版式/白底/产品说明/材质说明,只做有限替换;copy_locked_regenerate=文案与画面强绑定,必须锁文案;people_relation_locked=多人/模特/互动关系图,必须锁人物关系。详情替换不允许 direct_passthrough。" : "3. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。", isDetailReplacement ? "4. 判断 promptPackPolicy:只有通用空间场景且没有强文案、白底、多人关系、词图强绑定限制时才 allow;白底图、强文案图、多人关系图、图文匹配强的详情页必须 forbid。" @@ -1164,7 +1269,7 @@ export async function analyzeReplacementExecutionPlan(params: { ? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。" : "", isDetailReplacement - ? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/模特互动时必须 preserve_relation;如果没有提供用户模特图但参考图有人物身份,不允许把参考人物身份当成用户模特直接迁移。" + ? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/模特互动时必须 preserve_relation;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群;如果没有提供用户模特图但参考图有人物身份,不允许把参考人物身份当成用户模特直接迁移。" : "", isDetailReplacement ? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。" @@ -1194,7 +1299,7 @@ export async function analyzeReplacementExecutionPlan(params: { : "", "只返回严格 JSON,不要任何额外解释。", `JSON 格式: -{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","generationRoute":"prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`, +{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`, ] .filter(Boolean) .join("\n"); @@ -1233,15 +1338,24 @@ export async function analyzeReplacementExecutionPlan(params: { }); if (parsedDecision && parsedScenePlan) { + const normalizedPlan = ensureDetailReferenceGenerates( + parsedDecision, + parsedScenePlan, + { + modeType: params.modeType, + referenceLabel: params.referenceLabel, + replacementStrategy: params.replacementStrategy, + }, + ); return { - ...parsedDecision, + ...normalizedPlan.decision, detectionModelName: params.planningRuntime.modelName, detectionSource: params.planningRuntime.kind === "local_moonshot" ? "local_moonshot" : "configured_vision", scenePlan: { - ...parsedScenePlan, + ...normalizedPlan.scenePlan, analysisModelName: params.planningRuntime.modelName, analysisSource: params.planningRuntime.kind === "local_moonshot" @@ -1265,17 +1379,27 @@ export async function analyzeReplacementExecutionPlan(params: { modeType: params.modeType, replacementStrategy: params.replacementStrategy, }); - return { - ...fallbackDecision, - scenePlan: buildHeuristicReplacementScenePlan({ - productImageCount: params.productImageUrls.length, - hasModelImage: isProductOnlyDetailReplacement - ? false - : Boolean(params.modelImageUrl), - shouldReplace: fallbackDecision.shouldReplace, - reason: fallbackDecision.reason, + const fallbackScenePlan = buildHeuristicReplacementScenePlan({ + productImageCount: params.productImageUrls.length, + hasModelImage: isProductOnlyDetailReplacement + ? false + : Boolean(params.modelImageUrl), + shouldReplace: fallbackDecision.shouldReplace, + reason: fallbackDecision.reason, + replacementStrategy: params.replacementStrategy, + }); + const normalizedFallbackPlan = ensureDetailReferenceGenerates( + fallbackDecision, + fallbackScenePlan, + { + modeType: params.modeType, + referenceLabel: params.referenceLabel, replacementStrategy: params.replacementStrategy, - }), + }, + ); + return { + ...normalizedFallbackPlan.decision, + scenePlan: normalizedFallbackPlan.scenePlan, }; } @@ -1334,14 +1458,14 @@ export function buildAgentReplacementPrompt(params: { ? `图片${nextImageIndex}` : `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`; imageOrderLines.push( - `${productRangeLabel}:同一产品的白底图/细节图,必须综合理解所有产品图,不可只看第一张。` + `${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略。` ); nextImageIndex += params.productImageCount; } if (params.hasModelImage) { imageOrderLines.push( - `图片${nextImageIndex}:模特图,仅在当前参考图本身包含人物展示时才允许参考。` + `图片${nextImageIndex}:模特图,仅在当前参考图本身包含人物展示时参考;如果参考图是多人,只把这张模特图作为主人物身份/气质参考,其余人物按参考图的人数、关系和风格补齐。` ); nextImageIndex += 1; } @@ -1410,6 +1534,9 @@ export function buildAgentReplacementPrompt(params: { ? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}` : "本屏不需要模特图,不允许把模特图强行融入结果。" : "", + params.scenePlan.peoplePolicy === "preserve_relation" && params.hasModelImage + ? "若当前参考图为多人/群体/互动关系图,即使只提供 1 张模特图,也必须保留参考图人数、相对位置、动作和关系;模特图作为主人物身份/气质参考,其余人物按参考图生成相近人群。" + : "", params.scenePlan.generationPrompt ? `本屏生成方向:${params.scenePlan.generationPrompt}` : "", @@ -1433,11 +1560,13 @@ export function buildAgentReplacementPrompt(params: { : "输出必须保持当前参考图页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。", "镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前产品区域。", params.productImageCount > 1 - ? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。` - : "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。", - "你的任务只有一个:识别参考图中的旧产品主体或产品展示区,用输入产品替换它,并让新产品与原场景自然融合。", + ? `前面的 ${params.productImageCount} 张上传参考图可能是同一产品的多角度图,也可能是材质、板材、原材料或细节图;请综合理解结构、材质、颜色、比例、纹理、边角、开孔、五金与细节,不可只参考第一张。` + : params.productImageCount === 1 + ? "前面的上传参考图可能是产品图、材质图、板材图、原材料图或细节图,请准确理解它的结构、材质、颜色、比例、纹理和细节。" + : "本轮没有专门产品/材质输入图,请依据模特图、模板补充图以及最后一张参考图里的材质描述/视觉区做最小必要处理,不允许整屏直出。", + "你的任务是先识别参考图中的旧产品主体或产品展示区;如果没有明确产品,就继续识别材质/板材/原材料视觉区或可局部处理区域,并让处理结果与原版式自然融合。", "不要重建背景,不要重画空间,不要调整文案,不要修改按钮、图标、标签、装饰元素,也不要改变人物、道具和环境光氛围。", - "如果参考图中没有清晰可替换的产品主体或产品展示区,应直接沿用参考图,不要硬生成。", + "如果参考图中没有清晰可替换的产品主体或产品展示区,也必须继续处理产品展示区、材质/板材/原材料视觉区或与上传图外观匹配的局部区域,不允许整屏直出。", "不要把多个产品角度同时塞进一张结果图,不要新增无关主体,不要删除任何关键版式或 UI 元素。", ] : [ @@ -1459,7 +1588,7 @@ export function buildAgentReplacementPrompt(params: { shouldApplyPromptPack ? "执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。" : "执行时必须保持参考图原有画面主题、文案语义、人物关系、构图和版式,只在可替换视觉区做有限替换。", - "允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图和必要的人物展示图区;不允许改动版式层。", + "允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图、材质/板材/原材料视觉区和必要的人物展示图区;不允许改动版式层。", "如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。", "最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。", params.scenePlan?.textPolicy === "lock" @@ -1478,14 +1607,16 @@ export function buildAgentReplacementPrompt(params: { : "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。", "镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。", shouldApplyPromptPack - ? "前面的商品图和模特图用于生成新的背景图/产品场景图内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。" - : "前面的商品图用于必要的产品/视觉区替换;模特图只在本屏明确需要人物且策略允许时使用,不得覆盖参考图原有语义、版式和图文关系。", + ? "前面的上传图和模特图用于生成新的背景图/产品场景图/材质视觉内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。" + : "前面的上传图用于必要的产品、材质、板材、原材料或视觉区替换;模特图只在本屏明确需要人物且策略允许时使用,不得覆盖参考图原有语义、版式和图文关系。", shouldApplyPromptPack - ? "可被替换重建的内容包括但不限于:背景照片、产品展示照片、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。" - : "可被替换的内容仅限参考图里明确的产品/视觉图片区;不要新增不属于参考图语义的空间、人物、道具、主题和文案。", + ? "可被替换重建的内容包括但不限于:背景照片、产品展示照片、板材、材质、原材料、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。" + : "可被替换的内容仅限参考图里明确的产品/材质/板材/原材料/视觉图片区;不要新增不属于参考图语义的空间、人物、道具、主题和文案。", params.productImageCount > 1 - ? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。` - : "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。", + ? `前面的 ${params.productImageCount} 张上传参考图可能是产品、多角度细节、材质、板材或原材料参考,请综合理解结构、材质、颜色、比例、纹理、边角、开孔、五金与细节,不可只参考第一张。` + : params.productImageCount === 1 + ? "前面的上传参考图可能是产品图、材质图、板材图、原材料图或细节图,请准确理解它的结构、材质、颜色、比例、纹理和细节。" + : "本轮没有专门产品/材质输入图,请依据模特图、模板补充图以及最后一张参考图里的材质描述/视觉区做最小必要处理,不允许整屏直出。", shouldApplyPromptPack ? "请先识别最后一张参考图中需要被替换的背景图/场景图区域,并在同一版式骨架下生成新的背景画面;不要只做局部抠图式换产品。" : "请先识别最后一张参考图中真正允许替换的视觉区;如果只是白底说明、强文案页、多人关系图或图文匹配强的内容,不要强行重建整张场景。", @@ -1495,15 +1626,15 @@ export function buildAgentReplacementPrompt(params: { shouldApplyPromptPack ? "如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。" : "如果第一步工作流风格与参考图原场景冲突,必须保留参考图语义与版式,不要放弃原有图文关系。", - "前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。", - "如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致。", + "前面的上传图用于提供商品主体、材质、板材、原材料、颜色、纹理和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。", + "如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致;如果参考图是多人但只提供 1 张模特图,保留参考图人数、关系、动作和站位,额外人物按参考图生成相近人群。", "绝对不要忽略最后一张参考图去生成一张无关的新场景图。", shouldApplyPromptPack ? "如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。" : "如果本屏已禁用词包,生成结果不应追求明显改造背景,而应追求图文匹配、文案稳定、人物关系稳定和局部替换自然。", "绝对不要移动、删除、改写、重排参考图里的任何文字、UI图标、按钮、标签、装饰元素与排版元素。", "输出结果必须保持最后一张参考图底板的原始纵横比与画布尺寸,不允许擅自改成长图、横图或裁掉任何版面区域。", - "如果参考图中没有可替换/重建的背景图、产品场景图或视觉图片区,应直接沿用参考图,不要硬生成。", + "如果参考图中没有产品主体,继续检查人物/模特展示;如果没有人物,则根据上传图外观和参考图中的材质描述处理板材、材质、原材料、背景或视觉图片区;不允许整屏直出。", "不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式或UI元素。", ]; @@ -1534,7 +1665,9 @@ export function buildAgentReplacementPrompt(params: { : skuInstructionLines).filter(Boolean); if (params.hasModelImage) { - instructionLines.push("如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致。"); + instructionLines.push( + "如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致;多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。" + ); } return [ diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 8fed6e34c..cb0d2111a 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -3167,16 +3167,6 @@ export async function POST(request: Request) { ? modelSourceImage.url : null; - if (productInputImageUrls.length === 0) { - return NextResponse.json( - { - code: "bad_request:api", - message: "详情参考图替换至少需要 1 张产品图", - }, - { status: 400 }, - ); - } - const directReplacementModels = await getEnabledAiModelsWithProvider().catch((error) => { chatApiLogger.warn("[Chat API] Failed to load models for direct detail replacement:", error); return undefined; @@ -3421,19 +3411,12 @@ export async function POST(request: Request) { ); if (!replacementExecutionPlan.shouldReplace) { - await emitReplacementResultMessage({ - reference, - bodyText: replacementExecutionPlan.reason, - image: { - originalUrl: reference.imageUrl, - thumbnailUrl: reference.imageUrl, - }, - modelDisplayName: null, - sourceImageUrls: referenceSourceImageUrls, - streamLabel: "agent-mode:image:detail-replacement:item", + chatApiLogger.warn("[Chat API] Direct detail replacement plan returned shouldReplace=false; forcing generation:", { + referenceIndex: referenceIndex + 1, + totalReferences: replacementReferences.length, + referenceLabel: reference.label, + reason: replacementExecutionPlan.reason, }); - successCount += 1; - return; } const referenceGeometry = await resolveReplacementReferenceGeometry(reference.imageUrl); @@ -3445,28 +3428,28 @@ export async function POST(request: Request) { selectedProductIndexes.length > 0 ? selectedProductIndexes.map((index) => productInputImageUrls[index]!) : productInputImageUrls.slice(0, Math.min(productInputImageUrls.length, 2)); + const shouldIncludeModelImage = + detailReplacementStrategy !== "product_only" && + Boolean(modelInputImageUrl) && + (replacementExecutionPlan.scenePlan.useModelImage || + selectedProductContextImageUrls.length === 0); const sourceImageUrls = Array.from( new Set([ - ...selectedProductContextImageUrls, - ...(replacementExecutionPlan.scenePlan.useModelImage && - detailReplacementStrategy !== "product_only" && - modelInputImageUrl - ? [modelInputImageUrl] - : []), - reference.imageUrl, + ...selectedProductContextImageUrls, + ...(shouldIncludeModelImage && modelInputImageUrl + ? [modelInputImageUrl] + : []), + reference.imageUrl, ]), ); - const replacementPrompt = buildAgentReplacementPrompt({ - basePrompt: baseReplacementPrompt, - modeType: "detail_replacement", - replacementStrategy: detailReplacementStrategy, - referenceLabel: reference.label, - referenceIndex, - totalReferences: replacementReferences.length, - hasModelImage: - replacementExecutionPlan.scenePlan.useModelImage && - detailReplacementStrategy !== "product_only" && - Boolean(modelInputImageUrl), + const replacementPrompt = buildAgentReplacementPrompt({ + basePrompt: baseReplacementPrompt, + modeType: "detail_replacement", + replacementStrategy: detailReplacementStrategy, + referenceLabel: reference.label, + referenceIndex, + totalReferences: replacementReferences.length, + hasModelImage: shouldIncludeModelImage, productImageCount: selectedProductContextImageUrls.length, originalProductImageCount: productInputImageUrls.length, templateReferenceCount: 0, @@ -3558,13 +3541,7 @@ export async function POST(request: Request) { image: finalImage, imageSizeLabel: finalImageSizeLabel, modelDisplayName: resolvedModelDisplayName, - sourceImageUrls: Array.from( - new Set([ - ...productInputImageUrls, - ...(modelInputImageUrl ? [modelInputImageUrl] : []), - ...replacementReferences.map((item) => item.imageUrl), - ]), - ), + sourceImageUrls, streamLabel: "agent-mode:image:detail-replacement:item", promptForPersistence: replacementPrompt, }); @@ -3855,13 +3832,15 @@ export async function POST(request: Request) { .filter(Boolean), ), ); - const productInputImageUrls = sharedUploadedImageUrls.slice( - 0, - Math.max(1, replacementProductSlotCount) - ); const modelInputImageUrl = replacementModelSlotEnabled ? sharedUploadedImageUrls[Math.max(0, replacementProductSlotCount)] || null : null; + const productInputImageUrls = isDetailReplacementPreset + ? sharedUploadedImageUrls.filter((url) => url !== modelInputImageUrl) + : sharedUploadedImageUrls.slice( + 0, + Math.max(1, replacementProductSlotCount) + ); const sourceAssetIds = Array.from( new Set( [ @@ -3895,7 +3874,7 @@ export async function POST(request: Request) { : "generic", }); - if (productInputImageUrls.length === 0) { + if (!isDetailReplacementPreset && productInputImageUrls.length === 0) { throw new Error("替换模式至少需要 1 张输入商品图"); } @@ -4169,7 +4148,7 @@ export async function POST(request: Request) { ]), ); - if (!replacementExecutionPlan.shouldReplace) { + if (!isDetailReplacementPreset && !replacementExecutionPlan.shouldReplace) { chatApiLogger.info("[Chat API] Replacement reference skipped and reused:", { mode: replacementSourceType, referenceIndex: referenceIndex + 1, @@ -4194,6 +4173,14 @@ export async function POST(request: Request) { successCount += 1; return; } + if (isDetailReplacementPreset && !replacementExecutionPlan.shouldReplace) { + chatApiLogger.warn("[Chat API] Detail replacement preset plan returned shouldReplace=false; forcing generation:", { + referenceIndex: referenceIndex + 1, + totalReferences: replacementReferences.length, + referenceLabel: reference.label, + reason: replacementExecutionPlan.reason, + }); + } const referenceGeometry = await resolveReplacementReferenceGeometry(reference.imageUrl); const generationAspectRatio = referenceGeometry?.aspectRatio || resolvedAspectRatio; @@ -4212,14 +4199,18 @@ export async function POST(request: Request) { 0, Math.min(orderedProductContextImageUrls.length, 2), ); + const shouldIncludeModelImage = + !(isDetailReplacementPreset && + detailReplacementStrategy === "product_only") && + Boolean(modelInputImageUrl) && + (replacementExecutionPlan.scenePlan.useModelImage || + (isDetailReplacementPreset && + selectedProductContextImageUrls.length === 0)); const sourceImageUrls = Array.from( new Set( [ ...selectedProductContextImageUrls, - ...(replacementExecutionPlan.scenePlan.useModelImage && - !(isDetailReplacementPreset && - detailReplacementStrategy === "product_only") && - modelInputImageUrl + ...(shouldIncludeModelImage && modelInputImageUrl ? [modelInputImageUrl] : []), ...replacementTemplateReferenceImageUrls, @@ -4239,11 +4230,7 @@ export async function POST(request: Request) { referenceLabel: reference.label, referenceIndex, totalReferences: replacementReferences.length, - hasModelImage: - replacementExecutionPlan.scenePlan.useModelImage && - !(isDetailReplacementPreset && - detailReplacementStrategy === "product_only") && - Boolean(modelInputImageUrl), + hasModelImage: shouldIncludeModelImage, productImageCount: selectedProductContextImageUrls.length, originalProductImageCount: productInputImageUrls.length, templateReferenceCount: replacementTemplateReferenceImageUrls.length, @@ -4263,9 +4250,7 @@ export async function POST(request: Request) { imageOrder: { productImages: selectedProductContextImageUrls.length, originalProductImages: productInputImageUrls.length, - hasModelImage: - replacementExecutionPlan.scenePlan.useModelImage && - Boolean(modelInputImageUrl), + hasModelImage: shouldIncludeModelImage, templateReferenceImages: replacementTemplateReferenceImageUrls.length, finalReferenceImageIndex: sourceImageUrls.length, }, diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index add5b4eb3..b69fd121d 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -940,9 +940,8 @@ function inferReplacementCardStatusFromText(text: string | null | undefined): Re } if ( - normalized.includes("沿用参考图") || - normalized.includes("未检测到可替换的家具主体") || - normalized.includes("视觉判断失败") + normalized.includes(MANUAL_PASSTHROUGH_RESULT_TEXT) || + normalized.includes("已按人工标记沿用参考图") ) { return "passthrough"; }