fix: force detail replacement generation

This commit is contained in:
Your Name 2026-06-02 15:39:39 +08:00
parent 3e2261b1c7
commit 0b9d41e7e2
3 changed files with 239 additions and 122 deletions

View File

@ -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<T extends ReplacementReferenceDecisionCore>(
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 [

View File

@ -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,
},

View File

@ -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";
}