ai-chatbot/app/(chat)/api/chat/_lib/detail-replacement.ts

2827 lines
143 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createLogger } from "@/lib/observability/logger";
import { generateText } from "ai";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { getEnabledAiModelsWithProvider, getSiteSettings } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema";
const chatApiLogger = createLogger("chat.detailReplacement");
export async function createAgentImageThumbnailBuffer(buffer: Buffer): Promise<Buffer> {
const sharp = (await import("sharp")).default;
return sharp(buffer, { failOn: "none" })
.rotate()
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82 })
.toBuffer();
}
const REPLACEMENT_OUTPUT_ASPECT_RATIOS = [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
] as const;
export type ReplacementReferenceGeometry = {
width: number;
height: number;
aspectRatio: string;
};
export type ReplacementReferenceVisualProfile = {
width: number;
height: number;
nearWhitePixelRatio: number;
lightLowSaturationPixelRatio: number;
topLightLowSaturationPixelRatio: number;
borderLightLowSaturationPixelRatio: number;
darkPixelRatio: number;
likelyWhiteLayout: boolean;
likelyWhiteScene: boolean;
reason: string;
};
export function parseReplacementAspectRatioValue(ratio: string) {
const [width, height] = ratio.split(":").map((item) => Number(item));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
}
return width / height;
}
export function inferClosestReplacementAspectRatio(width: number, height: number) {
const aspectValue = width / Math.max(1, height);
let closest = "1:1";
let minDiff = Number.POSITIVE_INFINITY;
for (const candidate of REPLACEMENT_OUTPUT_ASPECT_RATIOS) {
const diff = Math.abs(parseReplacementAspectRatioValue(candidate) - aspectValue);
if (diff < minDiff) {
minDiff = diff;
closest = candidate;
}
}
return closest;
}
export async function resolveReplacementReferenceGeometry(
referenceImageUrl: string,
): Promise<ReplacementReferenceGeometry | null> {
try {
const sharp = (await import("sharp")).default;
const referenceBuffer = await fetchImageBufferFromUrl(referenceImageUrl);
const metadata = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.metadata();
const width = metadata.width;
const height = metadata.height;
if (!width || !height) {
return null;
}
return {
width,
height,
aspectRatio: inferClosestReplacementAspectRatio(width, height),
};
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to resolve replacement reference geometry:", {
referenceImageUrl,
error: error instanceof Error ? error.message : "unknown",
});
return null;
}
}
export async function resolveReplacementReferenceVisualProfile(
referenceImageUrl: string,
): Promise<ReplacementReferenceVisualProfile | null> {
try {
const sharp = (await import("sharp")).default;
const referenceBuffer = await fetchImageBufferFromUrl(referenceImageUrl);
const { data, info } = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.resize(160, 160, {
fit: "inside",
withoutEnlargement: true,
})
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const pixelCount = Math.max(1, info.width * info.height);
let nearWhiteCount = 0;
let lightLowSaturationCount = 0;
let topLightLowSaturationCount = 0;
let topPixelCount = 0;
let borderLightLowSaturationCount = 0;
let borderPixelCount = 0;
let darkCount = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
const pixelIndex = Math.floor(offset / info.channels);
const x = pixelIndex % info.width;
const y = Math.floor(pixelIndex / info.width);
const red = data[offset] || 0;
const green = data[offset + 1] || 0;
const blue = data[offset + 2] || 0;
const maxChannel = Math.max(red, green, blue);
const minChannel = Math.min(red, green, blue);
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
const saturationSpan = maxChannel - minChannel;
const isLightLowSaturation = luminance >= 0.82 && saturationSpan <= 42;
const isTopBand = y <= Math.round(info.height * 0.32);
const isBorderBand =
x <= Math.round(info.width * 0.06) ||
x >= Math.round(info.width * 0.94) ||
y <= Math.round(info.height * 0.08) ||
y >= Math.round(info.height * 0.92);
if (red >= 238 && green >= 238 && blue >= 238) {
nearWhiteCount += 1;
}
if (isLightLowSaturation) {
lightLowSaturationCount += 1;
}
if (isTopBand) {
topPixelCount += 1;
if (isLightLowSaturation) {
topLightLowSaturationCount += 1;
}
}
if (isBorderBand) {
borderPixelCount += 1;
if (isLightLowSaturation) {
borderLightLowSaturationCount += 1;
}
}
if (luminance <= 0.2) {
darkCount += 1;
}
}
const nearWhitePixelRatio = nearWhiteCount / pixelCount;
const lightLowSaturationPixelRatio = lightLowSaturationCount / pixelCount;
const topLightLowSaturationPixelRatio =
topLightLowSaturationCount / Math.max(1, topPixelCount);
const borderLightLowSaturationPixelRatio =
borderLightLowSaturationCount / Math.max(1, borderPixelCount);
const darkPixelRatio = darkCount / pixelCount;
const likelyWhiteScene =
(lightLowSaturationPixelRatio >= 0.5 && darkPixelRatio <= 0.28) ||
(topLightLowSaturationPixelRatio >= 0.68 &&
borderLightLowSaturationPixelRatio >= 0.52 &&
darkPixelRatio <= 0.32);
const likelyWhiteLayout =
nearWhitePixelRatio >= 0.42 ||
(nearWhitePixelRatio >= 0.3 &&
lightLowSaturationPixelRatio >= 0.58 &&
darkPixelRatio <= 0.18) ||
(topLightLowSaturationPixelRatio >= 0.72 &&
borderLightLowSaturationPixelRatio >= 0.6 &&
darkPixelRatio <= 0.24);
return {
width: info.width,
height: info.height,
nearWhitePixelRatio,
lightLowSaturationPixelRatio,
topLightLowSaturationPixelRatio,
borderLightLowSaturationPixelRatio,
darkPixelRatio,
likelyWhiteLayout,
likelyWhiteScene,
reason: likelyWhiteLayout
? "像素初筛显示大面积白底/浅色低饱和版式区域,按白底说明图保护"
: likelyWhiteScene
? "像素初筛显示白/浅色低饱和真实场景,按白场景基调软重造"
: "像素初筛未达到白底/白场景阈值",
};
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to resolve replacement reference visual profile:", {
referenceImageUrl,
error: error instanceof Error ? error.message : "unknown",
});
return null;
}
}
export async function normalizeReplacementResultImage(params: {
generatedImageUrl: string;
generatedThumbnailUrl?: string;
referenceImageUrl: string;
referenceGeometry?: ReplacementReferenceGeometry | null;
scenePlan?: ReplacementScenePlan | null;
uploadPrefix: string;
}): Promise<{
originalUrl: string;
thumbnailUrl: string;
imageSize: string;
}> {
const sharp = (await import("sharp")).default;
const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl);
let targetWidth = params.referenceGeometry?.width;
let targetHeight = params.referenceGeometry?.height;
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.metadata();
if (!targetWidth || !targetHeight) {
try {
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.metadata();
targetWidth = referenceMetadata.width;
targetHeight = referenceMetadata.height;
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to read replacement reference size, hosting generated output as-is:", {
referenceImageUrl: params.referenceImageUrl,
generatedImageUrl: params.generatedImageUrl,
error: error instanceof Error ? error.message : "unknown",
});
}
}
const outputWidth = targetWidth || generatedMetadata.width;
const outputHeight = targetHeight || generatedMetadata.height;
if (!outputWidth || !outputHeight) {
throw new Error("无法读取结果图尺寸");
}
const imagePipeline = sharp(generatedBuffer, { failOn: "none" }).rotate();
const targetAspectRatio =
targetWidth && targetHeight ? targetWidth / targetHeight : null;
const generatedAspectRatio =
generatedMetadata.width && generatedMetadata.height
? generatedMetadata.width / generatedMetadata.height
: null;
const shouldFillSceneCanvas =
params.scenePlan?.promptPackPolicy !== "forbid" &&
(params.scenePlan?.generationRoute === "prompt_pack_scene" ||
params.scenePlan?.generationRoute === "people_relation_locked");
const shouldPreserveFullGeneratedCanvas =
!shouldFillSceneCanvas &&
(params.scenePlan?.whiteBackgroundLikely === true ||
params.scenePlan?.textPolicy === "lock" ||
params.scenePlan?.generationRoute === "copy_locked_regenerate" ||
params.scenePlan?.generationRoute === "layout_preserve_replace");
const shouldCropTallComposite =
!shouldPreserveFullGeneratedCanvas &&
Boolean(targetAspectRatio && generatedAspectRatio) &&
Number(generatedAspectRatio) < Number(targetAspectRatio) * 0.78;
const resizeFit = shouldCropTallComposite
? "cover"
: shouldPreserveFullGeneratedCanvas
? "contain"
: "cover";
const normalizedBuffer =
targetWidth && targetHeight
? await imagePipeline
.resize(targetWidth, targetHeight, {
// Layout-locked copy pages keep containment to protect edge text. Scene pages
// fill the target frame so a wrong model aspect does not become a white panel.
fit: resizeFit,
position: shouldCropTallComposite ? "top" : "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.png()
.toBuffer()
: await imagePipeline.png().toBuffer();
const thumbnailBuffer = await createAgentImageThumbnailBuffer(normalizedBuffer);
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const originalFilename = `${params.uploadPrefix}-orig-${uniqueSuffix}.png`;
const thumbnailFilename = `${params.uploadPrefix}-thumb-${uniqueSuffix}.jpg`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(originalFilename, bufferToArrayBuffer(normalizedBuffer)),
uploadFile(thumbnailFilename, bufferToArrayBuffer(thumbnailBuffer)),
]);
return {
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
imageSize: `${outputWidth}x${outputHeight}`,
};
}
export type AgentReplacementModePayload = NonNullable<PostRequestBody["agentModePayload"]>;
export type AgentModeSourceImagePayload = NonNullable<AgentReplacementModePayload["sourceImages"]>;
export type AgentDetailReplacementStrategy = "scene_replace" | "product_only";
export type DetailReplacementGenerationRoute =
| "prompt_pack_scene"
| "layout_preserve_replace"
| "copy_locked_regenerate"
| "people_relation_locked"
| "direct_passthrough";
export type DetailReplacementPromptPackPolicy = "allow" | "soft" | "forbid";
export type DetailReplacementTextPolicy = "free" | "preserve" | "lock";
export type DetailReplacementPeoplePolicy =
| "none"
| "preserve_relation"
| "require_user_model";
export type DetailReplacementPageKind =
| "prompt_pack_scene"
| "white_scene"
| "white_layout"
| "copy_locked"
| "people_scene"
| "action_poster"
| "dimension_spec"
| "material_detail"
| "unknown";
export type NormalizedAgentModeSourceImage = {
url: string;
label: string;
name: string;
assetId: string | null;
};
export function isMarketplaceProductDetailReferenceId(value: unknown) {
return /^product-id-(taobao|1688|douyin|jd)-/i.test(String(value || "").trim());
}
export type ReplacementReferenceDecision = {
shouldReplace: boolean;
containsFurnitureProduct: boolean;
reason: string;
detectionModelName?: string | null;
detectionSource:
| "local_moonshot"
| "configured_vision"
| "configured_qwen3_vl"
| "fallback_qwen3_vl"
| "safe_passthrough";
};
export type ReplacementScenePlan = {
pageKind: DetailReplacementPageKind;
generationRoute: DetailReplacementGenerationRoute;
promptPackPolicy: DetailReplacementPromptPackPolicy;
textPolicy: DetailReplacementTextPolicy;
peoplePolicy: DetailReplacementPeoplePolicy;
whiteBackgroundLikely: boolean;
copyImageCoupled: boolean;
routeReason: string;
useModelImage: boolean;
modelImageReason: string;
sceneSummary: string;
cameraSummary: string;
placementSummary: string;
preserveSummary: string;
styleSummary: string;
avoidSummary: string;
generationPrompt: string;
selectedProductImageIndexes: number[];
productSelectionReason: string;
analysisModelName?: string | null;
analysisSource: "local_moonshot" | "configured_vision" | "heuristic";
};
export type ReplacementExecutionPlan = ReplacementReferenceDecision & {
scenePlan: ReplacementScenePlan;
};
type ReplacementReferenceDecisionCore = Omit<
ReplacementReferenceDecision,
"detectionModelName" | "detectionSource"
>;
const DETAIL_HARD_LAYOUT_HINT_PATTERN =
/产品尺寸|尺寸|规格|参数|单位|厘米|\bcm\b|产品说明|说明图|信息图|参数图|规格图|材质标注|工艺标注|结构标注|功能图标|图标说明|尺寸标注|承重参数|安装说明|标签图|卖点卡|白底产品|白底参数|白底规格/i;
const DETAIL_DIMENSION_LAYOUT_HINT_PATTERN =
/产品尺寸|尺寸图|尺寸说明|尺寸标注|尺寸线|标尺|长宽高|宽度|高度|深度|单位[::]?\s*(cm|厘米)|\bcm\b|160\s*\/\s*180\s*\/\s*210/i;
const DETAIL_STRONG_COPY_HINT_PATTERN =
/白底|浅色底|产品说明|参数|规格|尺寸|卖点|标签|说明图|信息图|海报|版式|排版|文字|标题|编号|标注|材质标注|工艺标注|代名词|典雅|高贵/i;
const DETAIL_REFERENCE_POSTER_ACTION_HINT_PATTERN =
/动作海报|测试海报|测试图|来张自拍|自拍|合照|多人围坐|围坐|聚餐|多人聚餐|横向受力|受力测试|单侧承重|承重测试|重心|依旧平稳|依然稳固|试坐|坐在桌|坐桌|坐上桌/i;
const DETAIL_REFERENCE_STABILITY_TEST_ACTION_HINT_PATTERN =
/横向受力|受力测试|单侧承重|承重测试|重心|依旧平稳|依然稳固|试坐|坐在桌|坐桌|坐上桌/i;
const DETAIL_PEOPLE_ACTION_HINT_PATTERN =
/自拍|合照|拍照|拿手机|手机自拍|聚会|聚餐|围坐|好友|朋友|闺蜜|夫妻|情侣|家庭|亲子|陪伴|举杯|互动|模特|人物|人像|多人|人群|男士|女士|男人|女人|男生|女生|小孩|儿童|宝宝|顾客|吃饭的人|用餐的人|正在吃饭|坐在|坐着|站在|走动|行走|手持|试坐|倚靠|承重|受力|受力测试|重心|测试|稳定|稳固|使用产品|产品使用|体验/i;
export type ReplacementScenePlanningRuntime =
| {
kind: "local_moonshot";
model: any;
modelName: string;
}
| {
kind: "configured_vision";
model: any;
modelName: string;
}
| null;
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
export function normalizeDetailReplacementStrategy(
value: unknown,
): AgentDetailReplacementStrategy {
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"
: scenePlan.whiteBackgroundLikely
? "layout_preserve_replace"
: "prompt_pack_scene";
const forcedReason = isStitchedReference
? "拼合切片未勾选不处理,继续执行详情替换"
: "详情替换未勾选不处理,继续执行必要替换";
const forcedGenerationPrompt =
forcedRoute === "prompt_pack_scene"
? "保留当前详情图的版式、文案、图标、构图和留白,但必须按前置工作流词包重建旧背景、空间、软装、灯光和产品场景;先寻找可替换产品/产品展示区,不允许整屏直出。"
: "保留当前详情图的版式、文案、图标、构图和留白;先寻找可替换产品/产品展示区,没有产品则检查人物/模特展示,没有人物则根据上传图外观与参考图材质描述处理板材、材质、原材料、背景或视觉图片区,不允许整屏直出。";
return {
decision: {
...decision,
shouldReplace: true,
containsFurnitureProduct: true,
reason: forcedReason,
},
scenePlan: {
...scenePlan,
generationRoute:
scenePlan.generationRoute === "direct_passthrough"
? forcedRoute
: scenePlan.generationRoute,
promptPackPolicy:
scenePlan.generationRoute === "direct_passthrough"
? inferPromptPackPolicy(forcedRoute)
: scenePlan.promptPackPolicy,
pageKind:
scenePlan.generationRoute === "direct_passthrough" ||
scenePlan.pageKind === "unknown"
? inferDetailPageKind({
route: forcedRoute,
promptPackPolicy: inferPromptPackPolicy(forcedRoute),
textPolicy: scenePlan.textPolicy,
peoplePolicy: scenePlan.peoplePolicy,
whiteBackgroundLikely: scenePlan.whiteBackgroundLikely,
copyImageCoupled: scenePlan.copyImageCoupled,
hasDimensionLayoutHint: false,
hasReferencePosterActionHint: false,
})
: scenePlan.pageKind,
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);
}
function normalizeDetailGenerationRoute(
value: unknown,
): DetailReplacementGenerationRoute | null {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
if (!normalized) {
return null;
}
if (
normalized === "prompt_pack_scene" ||
normalized === "scene" ||
normalized === "prompt_scene" ||
normalized === "use_prompt_pack"
) {
return "prompt_pack_scene";
}
if (
normalized === "layout_preserve_replace" ||
normalized === "layout_preserve" ||
normalized === "preserve_layout" ||
normalized === "product_replace"
) {
return "layout_preserve_replace";
}
if (
normalized === "copy_locked_regenerate" ||
normalized === "copy_locked" ||
normalized === "text_locked" ||
normalized === "lock_copy"
) {
return "copy_locked_regenerate";
}
if (
normalized === "people_relation_locked" ||
normalized === "people_locked" ||
normalized === "model_relation_locked" ||
normalized === "preserve_people"
) {
return "people_relation_locked";
}
if (
normalized === "direct_passthrough" ||
normalized === "passthrough" ||
normalized === "keep_original" ||
normalized === "skip"
) {
return "direct_passthrough";
}
return null;
}
function normalizePromptPackPolicy(
value: unknown,
): DetailReplacementPromptPackPolicy | null {
if (typeof value === "boolean") {
return value ? "allow" : "forbid";
}
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return null;
}
if (["allow", "allowed", "true", "yes", "use", "enable", "启用", "使用", "允许"].includes(normalized)) {
return "allow";
}
if (["soft", "weak", "limited", "reference", "参考", "弱化", "柔性"].includes(normalized)) {
return "soft";
}
if (["forbid", "forbidden", "false", "no", "disable", "禁用", "禁止", "不使用"].includes(normalized)) {
return "forbid";
}
return null;
}
function normalizeTextPolicy(value: unknown): DetailReplacementTextPolicy | null {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return null;
}
if (["free", "rewrite", "自由", "可改写"].includes(normalized)) {
return "free";
}
if (["preserve", "keep", "保留", "保持"].includes(normalized)) {
return "preserve";
}
if (["lock", "locked", "copy_locked", "锁定", "锁文案", "严格保留"].includes(normalized)) {
return "lock";
}
return null;
}
function normalizePeoplePolicy(value: unknown): DetailReplacementPeoplePolicy | null {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return null;
}
if (["none", "no_people", "无", "无人"].includes(normalized)) {
return "none";
}
if (
[
"preserve_relation",
"preserve",
"relation_locked",
"keep_people",
"保留关系",
"锁定人物关系",
].includes(normalized)
) {
return "preserve_relation";
}
if (
[
"require_user_model",
"require_model",
"user_model_only",
"需要用户模特",
"必须用户模特",
].includes(normalized)
) {
return "require_user_model";
}
return null;
}
function normalizeDetailPageKind(value: unknown): DetailReplacementPageKind | null {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
if (!normalized) {
return null;
}
if (
[
"prompt_pack_scene",
"generic_scene",
"scene",
"space_scene",
"场景图",
"通用场景",
"吃词包场景",
].includes(normalized)
) {
return "prompt_pack_scene";
}
if (
[
"white_scene",
"light_scene",
"bright_scene",
"白场景",
"浅色场景",
"白色真实场景",
].includes(normalized)
) {
return "white_scene";
}
if (
[
"white_layout",
"layout",
"white_background_layout",
"white_poster",
"info_layout",
"白底说明",
"白底排版",
"说明页",
].includes(normalized)
) {
return "white_layout";
}
if (
[
"copy_locked",
"copy_locked_regenerate",
"text_locked",
"text_image_coupled",
"strong_copy",
"强文案",
"图文绑定",
].includes(normalized)
) {
return "copy_locked";
}
if (
[
"people_scene",
"people_relation",
"people_relation_locked",
"model_scene",
"人物场景",
"模特场景",
].includes(normalized)
) {
return "people_scene";
}
if (
[
"action_poster",
"action_test_poster",
"test_poster",
"people_action_poster",
"动作海报",
"测试海报",
"承重海报",
].includes(normalized)
) {
return "action_poster";
}
if (
[
"dimension_spec",
"dimension_layout",
"size_spec",
"size_chart",
"product_dimension",
"尺寸说明",
"产品尺寸",
"尺寸图",
].includes(normalized)
) {
return "dimension_spec";
}
if (
[
"material_detail",
"material_layout",
"material_spec",
"材质说明",
"板材说明",
"材料细节",
].includes(normalized)
) {
return "material_detail";
}
if (["unknown", "other", "其他", "未知"].includes(normalized)) {
return "unknown";
}
return null;
}
function inferDetailPageKind(params: {
route: DetailReplacementGenerationRoute;
promptPackPolicy?: DetailReplacementPromptPackPolicy;
textPolicy?: DetailReplacementTextPolicy;
peoplePolicy?: DetailReplacementPeoplePolicy;
whiteBackgroundLikely: boolean;
copyImageCoupled: boolean;
hasDimensionLayoutHint: boolean;
hasReferencePosterActionHint: boolean;
visualProfile?: ReplacementReferenceVisualProfile | null;
}): DetailReplacementPageKind {
if (params.hasDimensionLayoutHint) {
return "dimension_spec";
}
if (params.hasReferencePosterActionHint) {
return "action_poster";
}
if (params.route === "people_relation_locked") {
return params.promptPackPolicy === "forbid" ? "action_poster" : "people_scene";
}
if (params.route === "copy_locked_regenerate" || params.copyImageCoupled) {
return "copy_locked";
}
if (params.route === "layout_preserve_replace") {
return params.whiteBackgroundLikely ? "white_layout" : "material_detail";
}
if (params.visualProfile?.likelyWhiteScene && !params.visualProfile?.likelyWhiteLayout) {
return "white_scene";
}
if (params.route === "prompt_pack_scene") {
return "prompt_pack_scene";
}
return "unknown";
}
function inferDetailGenerationRoute(params: {
shouldReplace: boolean;
isProductOnly: boolean;
whiteBackgroundLikely: boolean;
copyImageCoupled: boolean;
hasImportantText: boolean;
containsPeople: boolean;
containsMultiplePeople: boolean;
}): DetailReplacementGenerationRoute {
if (!params.shouldReplace) {
return "direct_passthrough";
}
if (params.isProductOnly) {
return "layout_preserve_replace";
}
if (params.containsMultiplePeople || params.containsPeople) {
return "people_relation_locked";
}
if (params.whiteBackgroundLikely) {
return "layout_preserve_replace";
}
if (params.copyImageCoupled || params.hasImportantText) {
return "copy_locked_regenerate";
}
return "prompt_pack_scene";
}
function inferPromptPackPolicy(
route: DetailReplacementGenerationRoute,
): DetailReplacementPromptPackPolicy {
return route === "prompt_pack_scene" ? "allow" : "forbid";
}
function inferTextPolicy(params: {
route: DetailReplacementGenerationRoute;
hasImportantText: boolean;
copyImageCoupled: boolean;
}): DetailReplacementTextPolicy {
if (params.route === "copy_locked_regenerate" || params.copyImageCoupled) {
return "lock";
}
if (params.hasImportantText || params.route === "layout_preserve_replace") {
return "preserve";
}
return "free";
}
function inferPeoplePolicy(params: {
containsPeople: boolean;
containsMultiplePeople: boolean;
hasModelImage: boolean;
}): DetailReplacementPeoplePolicy {
if (!params.containsPeople && !params.containsMultiplePeople) {
return "none";
}
return "preserve_relation";
}
function planTextForPolicyGuards(scenePlan: ReplacementScenePlan) {
return [
scenePlan.pageKind,
scenePlan.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.preserveSummary,
scenePlan.styleSummary,
scenePlan.avoidSummary,
scenePlan.generationPrompt,
]
.filter(Boolean)
.join(" ")
.trim();
}
function appendUniqueInstruction(base: string, instruction: string, maxLength = 1200) {
const normalizedBase = String(base || "").trim();
const normalizedInstruction = String(instruction || "").trim();
if (!normalizedInstruction) {
return normalizedBase.slice(0, maxLength);
}
if (normalizedBase.includes(normalizedInstruction)) {
return normalizedBase.slice(0, maxLength);
}
return [normalizedBase, normalizedInstruction].filter(Boolean).join(";").slice(0, maxLength);
}
function applyDetailReplacementScenePlanGuards(
scenePlan: ReplacementScenePlan,
params: {
modeType?: "detail_replacement" | "sku_replacement";
hasModelImage: boolean;
hasImportantText: boolean;
containsPeople: boolean;
containsMultiplePeople: boolean;
visualProfile?: ReplacementReferenceVisualProfile | null;
replacementStrategy?: AgentDetailReplacementStrategy;
},
): ReplacementScenePlan {
if (params.modeType !== "detail_replacement") {
return scenePlan;
}
const isProductOnly = params.replacementStrategy === "product_only";
const planText = planTextForPolicyGuards(scenePlan);
const pageKind = scenePlan.pageKind || "unknown";
const alreadyAllowsPromptPack =
scenePlan.generationRoute === "prompt_pack_scene" &&
scenePlan.promptPackPolicy !== "forbid";
const shouldTrustPromptPackPageKind = pageKind === "prompt_pack_scene";
const shouldTrustPromptPackRoute =
shouldTrustPromptPackPageKind && alreadyAllowsPromptPack;
const hasDimensionLayoutHint =
pageKind === "dimension_spec" ||
(!shouldTrustPromptPackPageKind &&
DETAIL_DIMENSION_LAYOUT_HINT_PATTERN.test(planText));
const hasHardLayoutHint =
!shouldTrustPromptPackPageKind && DETAIL_HARD_LAYOUT_HINT_PATTERN.test(planText);
const hasReferencePosterActionHint =
pageKind === "action_poster" ||
(!shouldTrustPromptPackPageKind &&
DETAIL_REFERENCE_POSTER_ACTION_HINT_PATTERN.test(planText));
const hasPeopleActionHint =
!shouldTrustPromptPackPageKind && DETAIL_PEOPLE_ACTION_HINT_PATTERN.test(planText);
const hasPeopleSignals =
!isProductOnly &&
(params.containsPeople ||
params.containsMultiplePeople ||
scenePlan.peoplePolicy !== "none" ||
hasPeopleActionHint);
const visualProfileSuggestsWhiteLayout = params.visualProfile?.likelyWhiteLayout === true;
const visualProfileSuggestsWhiteScene =
params.visualProfile?.likelyWhiteScene === true &&
!visualProfileSuggestsWhiteLayout;
const visualProfileSuggestsLightBase =
params.visualProfile?.likelyWhiteScene === true ||
params.visualProfile?.likelyWhiteLayout === true;
const shouldProtectDimensionLayout = !isProductOnly && hasDimensionLayoutHint;
const shouldProtectReferencePosterAction =
!isProductOnly &&
hasPeopleSignals &&
!shouldProtectDimensionLayout &&
hasReferencePosterActionHint;
const scenePlanSuggestsWhiteLayout =
(pageKind === "white_layout" ||
scenePlan.whiteBackgroundLikely) &&
!visualProfileSuggestsWhiteScene &&
!hasPeopleSignals &&
(hasHardLayoutHint ||
(scenePlan.copyImageCoupled && scenePlan.textPolicy === "lock"));
const shouldProtectWhiteLayout =
!isProductOnly &&
!hasPeopleSignals &&
(pageKind === "white_layout" ||
(visualProfileSuggestsWhiteLayout &&
(hasHardLayoutHint ||
(scenePlan.copyImageCoupled && scenePlan.textPolicy === "lock"))) ||
scenePlanSuggestsWhiteLayout ||
(hasHardLayoutHint && !visualProfileSuggestsWhiteScene));
const shouldProtectWhiteScene =
!isProductOnly &&
!shouldProtectWhiteLayout &&
(pageKind === "white_scene" || visualProfileSuggestsLightBase);
const shouldProtectPeople =
!isProductOnly &&
(hasPeopleSignals || (!alreadyAllowsPromptPack && scenePlan.peoplePolicy !== "none"));
const shouldLockCopy =
!isProductOnly &&
(scenePlan.copyImageCoupled ||
pageKind === "copy_locked" ||
shouldProtectDimensionLayout ||
(!shouldTrustPromptPackRoute &&
(params.hasImportantText ||
scenePlan.textPolicy === "lock" ||
hasHardLayoutHint)));
let next: ReplacementScenePlan = { ...scenePlan };
if (shouldProtectDimensionLayout) {
next = {
...next,
pageKind: "dimension_spec",
generationRoute: "layout_preserve_replace",
promptPackPolicy: "forbid",
textPolicy: "lock",
peoplePolicy: "none",
useModelImage: false,
modelImageReason: params.hasModelImage
? "产品尺寸说明页不使用模特图,必须以产品抠图和尺寸标注为主。"
: next.modelImageReason,
routeReason: appendUniqueInstruction(
next.routeReason,
"命中产品尺寸/单位/尺寸线说明页,按白底抠图尺寸标注处理,禁止套用场景词包。",
240,
),
sceneSummary: appendUniqueInstruction(
next.sceneSummary,
"产品主体应抠出在白底或米白底上展示,保留产品外轮廓、比例、透视角度和尺寸标注关系。",
260,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是产品尺寸说明图:不使用前置词包;必须把产品主体抠出放在干净白底/米白底上,保留标题“产品尺寸”、单位 cm、尺寸箭头/尺寸线/数字标注和原版式位置;产品形状、桌面比例、边角圆角、腿部结构、透视角度必须跟参考图一致;禁止生成厨房、餐桌椅组合、室内背景、人物、软装或多余道具。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要把尺寸页做成室内场景,不要新增椅子、柜体、灯具、花瓶或人物,不要改变产品形状,不要丢失尺寸线和单位。",
320,
),
whiteBackgroundLikely: true,
copyImageCoupled: true,
};
} else if (shouldProtectReferencePosterAction) {
next = {
...next,
pageKind: "action_poster",
generationRoute: "people_relation_locked",
promptPackPolicy: "soft",
textPolicy: next.textPolicy === "free" ? "lock" : next.textPolicy,
peoplePolicy: "preserve_relation",
useModelImage: params.hasModelImage,
modelImageReason: params.hasModelImage
? next.modelImageReason ||
"参考图是带明确人物动作的详情海报,模特图只用于人物身份/气质参考,不能覆盖参考图人数、动作和版式。"
: next.modelImageReason ||
"参考图是带明确人物动作的详情海报,必须按参考图人数、动作、姿态和版式生成类似图。",
routeReason: appendUniqueInstruction(
next.routeReason,
"命中参考动作海报,参考图中的具体动作和版式优先,但前置场景词包必须完整重建原照片内容区以避免原图复刻。",
240,
),
sceneSummary: appendUniqueInstruction(
next.sceneSummary,
"按参考图做同版式类似图:保留参考图原有的人物数量、具体动作、产品形状、机位和图文关系,同时按前置词包重建原照片内容区里的背景、空间、软装和灯光;不要画新增边框。",
320,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是参考动作海报,前置场景词包必须完整重建原照片内容区:参考图标题/标签/画面里的具体动作语义是硬约束。必须根据参考图做一张同版式、同动作、同人物关系的类似详情图:参考图是多人互动就保持多人互动,参考图是测试展示就保持对应测试姿态;产品主体的形状、外轮廓、长宽比例、桌面圆角、腿部结构和透视角度必须跟参考图一致;同时要按前置词包明显重建原照片内容区里的旧背景、空间、软装、灯光和产品场景,不能只复刻原图;不要绘制新增白色边框、线框或卡片描边。禁止生成走动路人、无人空场景或改成另一种产品形状。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要让前置词包覆盖参考动作,不要改变参考图原有人数、动作和产品形状;也不要只原图复刻、轻微修饰或画出新增边框。",
340,
),
whiteBackgroundLikely: next.whiteBackgroundLikely || visualProfileSuggestsLightBase,
copyImageCoupled: true,
};
} else if (
pageKind === "prompt_pack_scene" &&
!shouldProtectWhiteLayout &&
!shouldProtectPeople &&
!shouldLockCopy
) {
next = {
...next,
pageKind: "prompt_pack_scene",
generationRoute: "prompt_pack_scene",
promptPackPolicy: "allow",
textPolicy: next.textPolicy === "lock" ? "preserve" : next.textPolicy,
peoplePolicy: "none",
useModelImage: false,
copyImageCoupled: false,
routeReason: appendUniqueInstruction(
next.routeReason,
"多模态已判定为普通可吃词包场景页,纠正为使用前置词包重建场景。",
240,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是普通空间/场景详情页:必须使用前置工作流词包重建图片框内旧背景、产品、空间、软装和灯光,同时保留参考图版式骨架、文字区、机位和信息层级。",
),
};
} else if (shouldProtectPeople && shouldProtectWhiteLayout) {
next = {
...next,
pageKind: "white_layout",
generationRoute: "layout_preserve_replace",
promptPackPolicy: "forbid",
textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy,
peoplePolicy: shouldProtectPeople ? "preserve_relation" : next.peoplePolicy,
useModelImage: params.hasModelImage && shouldProtectPeople,
modelImageReason: params.hasModelImage
? next.modelImageReason ||
"当前参考页有人物/动作,但参考图为白底/浅色排版页,模特只用于保持人物身份、动作和姿态,不用于重建复杂空间。"
: next.modelImageReason ||
"当前参考页有人物/动作,但参考图为白底/浅色排版页;应保留人物数量、动作和关系,不要生成复杂家居场景。",
routeReason: appendUniqueInstruction(
next.routeReason,
"命中白底排版 + 人物/动作语义,锁定白底版式和人物关系,禁止套用场景词包。",
220,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是白底/浅色详情排版页:必须保持白底或浅色底、标题区、标签、图片框、留白、人物数量/动作/关系和产品位置;不使用前置词包,不生成厨房、客厅、黑墙、吊灯或复杂软装场景。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要把白底排版人物页改成复杂室内场景,不要让前置词包覆盖白底版式,不要把人物动作变成走动路人或无关模特。",
280,
),
whiteBackgroundLikely: true,
copyImageCoupled: next.copyImageCoupled || shouldProtectWhiteLayout,
};
} else if (shouldProtectPeople && shouldProtectWhiteScene) {
next = {
...next,
pageKind: "white_scene",
generationRoute: "people_relation_locked",
promptPackPolicy: "soft",
textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy,
peoplePolicy: "preserve_relation",
useModelImage: params.hasModelImage,
modelImageReason: params.hasModelImage
? next.modelImageReason ||
"当前参考页为白/浅色人物场景,模特用于保持人物身份、动作和姿态,词包用于重建白场景内的产品与空间。"
: next.modelImageReason ||
"当前参考页为白/浅色人物场景;需保留人物数量、动作和关系,同时按词包重建白场景内的产品与空间。",
routeReason: appendUniqueInstruction(
next.routeReason,
"命中白/浅色真实场景 + 人物/动作语义,允许词包重造场景,但必须保持白/浅色基调和原版式。",
240,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是白/浅色真实场景页:必须使用前置工作流词包重造图片框内的产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白色、米白、浅灰、浅木色的干净低饱和基调,保留人物数量、动作、朝向、互动关系、文字层级和原图片框边界。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要只小幅改动原图,不要原样保留旧场景;也不要把白场景改成深色厨房、黑墙、复杂吊灯或强软装背景。",
300,
),
whiteBackgroundLikely: true,
};
} else if (shouldProtectPeople) {
next = {
...next,
pageKind: "people_scene",
generationRoute: "people_relation_locked",
promptPackPolicy: "allow",
textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy,
peoplePolicy: "preserve_relation",
useModelImage: params.hasModelImage,
modelImageReason: params.hasModelImage
? next.modelImageReason ||
"当前参考页存在人物/自拍/互动需求,必须使用用户模特图保持人物身份与动作关系。"
: next.modelImageReason ||
"当前参考页存在人物/自拍/互动需求;未提供用户模特图时仍需保留参考页人物数量、动作和关系,但不要复刻特定身份。",
routeReason: appendUniqueInstruction(
next.routeReason,
"命中人物/自拍/互动语义,锁定人物关系与文案层级,但允许前置词包重建背景/产品场景。",
220,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"如果参考图文字或画面包含自拍、合照、聚会、用餐、互动、拿手机、承重测试、使用产品等动作语义,人物数量、相对位置、动作方向和出镜比例是硬约束;同时必须按前置工作流词包替换旧背景、空间氛围、灯光、软装和产品场景,不允许只复刻参考图原背景。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要把有人物/自拍动作的参考页生成成无人物空间,不要把多人压成单人,不要忽略参考页文字描述的人物动作,也不要只原样保留参考图旧场景。",
260,
),
};
} else if (shouldLockCopy) {
next = {
...next,
pageKind: pageKind === "copy_locked"
? "copy_locked"
: shouldProtectWhiteLayout
? "white_layout"
: "copy_locked",
generationRoute: shouldProtectWhiteLayout && pageKind !== "copy_locked"
? "layout_preserve_replace"
: "copy_locked_regenerate",
promptPackPolicy: "forbid",
textPolicy: "lock",
peoplePolicy: "none",
useModelImage: false,
routeReason: appendUniqueInstruction(
next.routeReason,
shouldProtectWhiteLayout && pageKind !== "copy_locked"
? "命中白底/说明图或强文案版式,禁止套用场景词包。"
: "命中强图文绑定,禁止套用场景词包。",
220,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
shouldProtectWhiteLayout && pageKind !== "copy_locked"
? "本屏按白底产品说明/参数排版处理:严格保留白底、留白、标题、标签、参数、文字颜色层级和产品位置,只做必要的产品/材质/视觉区替换,不允许生成室内场景、厨房、客厅、人物或软装背景。"
: "本屏文案与画面强绑定:文字内容、颜色、位置、层级和对应画面语义必须一致,只做必要局部替换,不允许按前置词包改造成另一套场景。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要把白底说明页场景化,不要新增厨房/客厅/人物/软装,不要让文案和图片失配,不要把文字拆成多种颜色。",
260,
),
whiteBackgroundLikely: next.whiteBackgroundLikely || shouldProtectWhiteLayout,
copyImageCoupled: true,
};
} else if (shouldProtectWhiteLayout) {
next = {
...next,
pageKind: "white_layout",
generationRoute: "layout_preserve_replace",
promptPackPolicy: "forbid",
textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy,
useModelImage: false,
routeReason: appendUniqueInstruction(
next.routeReason,
"命中白底/浅色说明图,按版式保护处理。",
220,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是白底/浅色产品说明版式:不要套用空间、人群、灯光、机位等前置词包,只保留参考图白底排版并做必要局部替换。",
),
whiteBackgroundLikely: true,
};
} else if (shouldProtectWhiteScene) {
next = {
...next,
pageKind: "white_scene",
generationRoute: "prompt_pack_scene",
promptPackPolicy: "soft",
textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy,
useModelImage: false,
routeReason: appendUniqueInstruction(
next.routeReason,
"命中白/浅色真实场景,允许词包重造场景,但必须保持白场景基调。",
240,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是白/浅色低饱和真实场景:必须使用前置工作流词包重造图片框内的背景、产品、材质、软装和灯光,使新旧差异清晰;同时保持白色、米白、浅灰、浅木色的干净低饱和场景基调,不要变成深色厨房、黑墙、复杂吊灯或强软装。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要只复刻原图或小幅改动,不要把白场景改成深色复杂空间,不要移动标题、标签和图片框。",
300,
),
whiteBackgroundLikely: true,
};
}
if (params.visualProfile?.likelyWhiteLayout && !next.routeReason.includes("像素初筛")) {
next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220);
}
if (
params.hasModelImage &&
!isProductOnly &&
!next.useModelImage &&
(next.peoplePolicy !== "none" ||
next.generationRoute === "people_relation_locked" ||
next.pageKind === "people_scene" ||
next.pageKind === "action_poster")
) {
next = {
...next,
useModelImage: true,
modelImageReason:
next.modelImageReason ||
"本屏已判定为人物/动作详情页,必须使用用户提供的模特图锁定人物身份。",
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏出现人物时必须使用用户提供的同一模特:脸型五官、发型发色、体型肤色和穿搭风格保持一致;不得换成其他人物。",
),
};
}
return next;
}
export function parseReplacementScenePlan(
text: string,
params: {
productImageCount: number;
hasModelImage: boolean;
modeType?: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
visualProfile?: ReplacementReferenceVisualProfile | null;
},
): ReplacementScenePlan | null {
const jsonText = extractJsonObjectText(text);
if (!jsonText) {
return null;
}
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const isProductOnly = params.replacementStrategy === "product_only";
const shouldReplace =
normalizeBooleanLike(parsed.shouldReplace) ??
normalizeBooleanLike(parsed.replace) ??
normalizeBooleanLike(parsed.isReplaceable) ??
true;
const whiteBackgroundLikely =
normalizeBooleanLike(parsed.whiteBackgroundLikely) ??
normalizeBooleanLike(parsed.whiteBackground) ??
normalizeBooleanLike(parsed.isWhiteBackground) ??
false;
const copyImageCoupled =
normalizeBooleanLike(parsed.copyImageCoupled) ??
normalizeBooleanLike(parsed.textImageCoupled) ??
normalizeBooleanLike(parsed.copyMatchesImage) ??
false;
const hasImportantText =
normalizeBooleanLike(parsed.hasImportantText) ??
normalizeBooleanLike(parsed.textHeavy) ??
normalizeBooleanLike(parsed.containsMarketingCopy) ??
normalizeBooleanLike(parsed.containsImportantCopy) ??
false;
const containsPeople =
normalizeBooleanLike(parsed.containsPeople) ??
normalizeBooleanLike(parsed.hasPeople) ??
normalizeBooleanLike(parsed.peoplePresent) ??
false;
const containsMultiplePeople =
normalizeBooleanLike(parsed.containsMultiplePeople) ??
normalizeBooleanLike(parsed.multiPerson) ??
normalizeBooleanLike(parsed.multiplePeople) ??
false;
const generationRoute =
normalizeDetailGenerationRoute(
parsed.generationRoute || parsed.route || parsed.recommendedRoute,
) ||
inferDetailGenerationRoute({
shouldReplace,
isProductOnly,
whiteBackgroundLikely,
copyImageCoupled,
hasImportantText,
containsPeople,
containsMultiplePeople,
});
const promptPackPolicy =
normalizePromptPackPolicy(
parsed.promptPackPolicy ?? parsed.usePromptPack ?? parsed.allowPromptPack,
) || inferPromptPackPolicy(generationRoute);
const textPolicy =
normalizeTextPolicy(parsed.textPolicy || parsed.copyPolicy) ||
inferTextPolicy({
route: generationRoute,
hasImportantText,
copyImageCoupled,
});
const peoplePolicy =
normalizePeoplePolicy(parsed.peoplePolicy || parsed.modelPolicy) ||
inferPeoplePolicy({
containsPeople,
containsMultiplePeople,
hasModelImage: params.hasModelImage,
});
const useModelImageRaw =
normalizeBooleanLike(parsed.useModelImage) ??
normalizeBooleanLike(parsed.needModelImage) ??
normalizeBooleanLike(parsed.referenceNeedsModel) ??
false;
const generationPrompt = truncateReplacementAnalysisText(
parsed.generationPrompt || parsed.recommendedScenePrompt || parsed.scenePrompt,
1200,
);
const rawPlanText = [
parsed.pageKind,
parsed.detailPageKind,
parsed.pageType,
parsed.referencePageKind,
parsed.routeReason,
parsed.strategyReason,
parsed.policyReason,
parsed.reason,
parsed.sceneSummary,
parsed.sceneDescription,
parsed.preserveSummary,
parsed.mustKeep,
parsed.styleSummary,
parsed.avoidSummary,
generationPrompt,
]
.filter(Boolean)
.join(" ");
const pageKind =
normalizeDetailPageKind(
parsed.pageKind ||
parsed.detailPageKind ||
parsed.pageType ||
parsed.referencePageKind,
) ||
inferDetailPageKind({
route: generationRoute,
promptPackPolicy,
textPolicy,
peoplePolicy,
whiteBackgroundLikely,
copyImageCoupled,
hasDimensionLayoutHint:
DETAIL_DIMENSION_LAYOUT_HINT_PATTERN.test(rawPlanText),
hasReferencePosterActionHint:
DETAIL_REFERENCE_POSTER_ACTION_HINT_PATTERN.test(rawPlanText),
visualProfile: params.visualProfile,
});
const productImageSelection = buildDefaultProductImageSelection(params.productImageCount);
let scenePlan: ReplacementScenePlan = {
pageKind,
generationRoute,
promptPackPolicy,
textPolicy,
peoplePolicy,
whiteBackgroundLikely,
copyImageCoupled,
routeReason: truncateReplacementAnalysisText(
parsed.routeReason || parsed.strategyReason || parsed.policyReason || parsed.reason,
180,
),
useModelImage: params.hasModelImage ? Boolean(useModelImageRaw) : false,
modelImageReason: truncateReplacementAnalysisText(
parsed.modelImageReason || parsed.modelReason,
160,
),
sceneSummary: truncateReplacementAnalysisText(
parsed.sceneSummary || parsed.sceneDescription,
220,
),
cameraSummary: truncateReplacementAnalysisText(
parsed.cameraSummary || parsed.cameraDescription,
180,
),
placementSummary: truncateReplacementAnalysisText(
parsed.placementSummary || parsed.productPlacement,
180,
),
preserveSummary: truncateReplacementAnalysisText(
parsed.preserveSummary || parsed.mustKeep || parsed.preserveInstruction,
220,
),
styleSummary: truncateReplacementAnalysisText(
parsed.styleSummary || parsed.styleInstruction || parsed.styleKeywords,
220,
),
avoidSummary: truncateReplacementAnalysisText(
parsed.avoidSummary || parsed.doNotInclude || parsed.avoidInstruction,
220,
),
generationPrompt,
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
productSelectionReason: productImageSelection.productSelectionReason,
analysisModelName: null,
analysisSource: "heuristic",
};
if (!scenePlan.generationPrompt) {
scenePlan.generationPrompt = [
scenePlan.routeReason,
scenePlan.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.styleSummary,
]
.filter(Boolean)
.join(";")
.slice(0, 1200);
}
scenePlan = applyDetailReplacementScenePlanGuards(scenePlan, {
modeType: params.modeType,
hasModelImage: params.hasModelImage,
hasImportantText,
containsPeople,
containsMultiplePeople,
visualProfile: params.visualProfile,
replacementStrategy: params.replacementStrategy,
});
return scenePlan;
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to parse replacement scene plan JSON:", error);
return null;
}
}
export function buildHeuristicReplacementScenePlan(params: {
modeType?: "detail_replacement" | "sku_replacement";
productImageCount: number;
hasModelImage: boolean;
shouldReplace: boolean;
reason: string;
replacementStrategy?: AgentDetailReplacementStrategy;
visualProfile?: ReplacementReferenceVisualProfile | null;
}): ReplacementScenePlan {
const isDetailReplacement = params.modeType === "detail_replacement";
const isProductOnly = params.replacementStrategy === "product_only";
const productImageSelection = buildDefaultProductImageSelection(params.productImageCount);
const fallbackText = String(params.reason || "");
const fallbackHasDimensionLayout =
DETAIL_DIMENSION_LAYOUT_HINT_PATTERN.test(fallbackText);
const fallbackHasReferencePosterAction =
DETAIL_REFERENCE_POSTER_ACTION_HINT_PATTERN.test(fallbackText);
const fallbackHasPeopleAction = DETAIL_PEOPLE_ACTION_HINT_PATTERN.test(fallbackText);
const fallbackHasHardLayout = DETAIL_HARD_LAYOUT_HINT_PATTERN.test(fallbackText);
const shouldProtectFallbackWhiteLayout =
isDetailReplacement &&
!isProductOnly &&
fallbackHasDimensionLayout;
const shouldProtectFallbackReferencePosterAction =
isDetailReplacement &&
!isProductOnly &&
!shouldProtectFallbackWhiteLayout &&
fallbackHasPeopleAction &&
fallbackHasReferencePosterAction;
const shouldProtectFallbackCopyLayout =
isDetailReplacement &&
!isProductOnly &&
!fallbackHasPeopleAction &&
(fallbackHasHardLayout ||
(Boolean(params.visualProfile?.likelyWhiteLayout) &&
DETAIL_STRONG_COPY_HINT_PATTERN.test(fallbackText)));
const shouldRebuildFallbackWhiteScene =
isDetailReplacement &&
!isProductOnly &&
!shouldProtectFallbackWhiteLayout &&
!shouldProtectFallbackReferencePosterAction &&
(fallbackHasPeopleAction ||
Boolean(params.visualProfile?.likelyWhiteScene) ||
Boolean(params.visualProfile?.likelyWhiteLayout));
const shouldProtectFallbackLayout =
shouldProtectFallbackWhiteLayout || shouldProtectFallbackCopyLayout;
const generationRoute = params.shouldReplace
? isProductOnly
? "layout_preserve_replace"
: isDetailReplacement
? shouldProtectFallbackWhiteLayout
? "layout_preserve_replace"
: fallbackHasPeopleAction
? "people_relation_locked"
: shouldProtectFallbackLayout
? "layout_preserve_replace"
: "prompt_pack_scene"
: "prompt_pack_scene"
: "direct_passthrough";
const promptPackPolicy: DetailReplacementPromptPackPolicy =
params.shouldReplace && isDetailReplacement && !isProductOnly
? shouldProtectFallbackWhiteLayout
? "forbid"
: shouldProtectFallbackReferencePosterAction
? "soft"
: fallbackHasPeopleAction
? shouldRebuildFallbackWhiteScene
? "soft"
: "allow"
: shouldRebuildFallbackWhiteScene
? "soft"
: inferPromptPackPolicy(generationRoute)
: inferPromptPackPolicy(generationRoute);
const fallbackPageKind = inferDetailPageKind({
route: generationRoute,
promptPackPolicy,
textPolicy:
shouldProtectFallbackWhiteLayout || shouldProtectFallbackReferencePosterAction
? "lock"
: fallbackHasPeopleAction || shouldRebuildFallbackWhiteScene
? "preserve"
: "free",
peoplePolicy: fallbackHasPeopleAction ? "preserve_relation" : "none",
whiteBackgroundLikely: Boolean(
params.visualProfile?.likelyWhiteLayout || params.visualProfile?.likelyWhiteScene,
),
copyImageCoupled: false,
hasDimensionLayoutHint: fallbackHasDimensionLayout,
hasReferencePosterActionHint: fallbackHasReferencePosterAction,
visualProfile: params.visualProfile,
});
const scenePlan: ReplacementScenePlan = {
pageKind: fallbackPageKind,
generationRoute,
promptPackPolicy,
textPolicy:
generationRoute === "direct_passthrough" || shouldProtectFallbackLayout
? shouldProtectFallbackWhiteLayout
? "lock"
: "preserve"
: shouldProtectFallbackReferencePosterAction
? "lock"
: fallbackHasPeopleAction || shouldRebuildFallbackWhiteScene
? "preserve"
: "free",
peoplePolicy: fallbackHasPeopleAction ? "preserve_relation" : "none",
whiteBackgroundLikely: Boolean(
params.visualProfile?.likelyWhiteLayout || params.visualProfile?.likelyWhiteScene,
),
copyImageCoupled: false,
routeReason: params.shouldReplace
? isProductOnly
? "兜底按仅替换产品执行,不使用词包。"
: isDetailReplacement
? shouldProtectFallbackWhiteLayout
? "兜底命中产品尺寸说明页,按白底抠图和尺寸标注执行,不使用词包。"
: shouldProtectFallbackReferencePosterAction
? "兜底命中参考动作海报,保留人物动作和产品形状,同时使用词包完整重建背景和产品场景。"
: fallbackHasPeopleAction
? "兜底命中人物/动作/使用场景,按人物关系锁定并允许词包重建背景和产品场景。"
: shouldProtectFallbackLayout
? "兜底命中白底说明/强文案版式,按详情版式保护执行,不使用词包。"
: shouldRebuildFallbackWhiteScene
? "兜底命中白/浅色真实场景,允许词包软重造,但保持白场景基调。"
: "兜底未命中白底、强文案或真人互动硬约束,按通用场景图执行词包场景替换。"
: "兜底未检测到强约束,允许使用词包生成场景。"
: params.reason,
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
productSelectionReason: productImageSelection.productSelectionReason,
useModelImage: false,
modelImageReason: params.hasModelImage
? isProductOnly
? "仅替换产品模式不使用模特图,默认锁定参考图原有人物与场景。"
: "前置分析未明确当前参考图存在人物展示需求,兜底阶段默认不使用模特图。"
: "当前未提供模特图。",
sceneSummary: params.shouldReplace
? isProductOnly
? "严格保留参考图的场景、文案和版式,只把产品展示区中的旧产品替换成输入产品。"
: shouldProtectFallbackWhiteLayout
? "产品主体抠出在白底或米白底中展示,保留产品尺寸标题、单位、尺寸线、尺寸数字和原版式关系。"
: shouldProtectFallbackReferencePosterAction
? "按参考图动作海报做类似图,保留人物数量、动作、产品形状、机位、标题标签和图文关系,同时按前置词包重建图片框内背景、空间、软装和灯光。"
: fallbackHasPeopleAction
? "保留参考图人物数量、动作、互动关系、文字层级和版式骨架,同时按工作流目标重建背景、产品、软装和灯光。"
: shouldProtectFallbackLayout
? "保留参考图白底说明版式、构图、文字和主体位置,只做必要的产品或视觉区替换。"
: shouldRebuildFallbackWhiteScene
? "保留参考图白/浅色低饱和基调与版式骨架,但按工作流目标明显重建图片框内产品、空间、软装和灯光。"
: "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。"
: "当前参考图不满足替换条件。",
cameraSummary: "保持参考图原有机位、构图和景别,不做整体重画。",
placementSummary: isProductOnly
? "产品必须落在参考图原有产品所在区域,保持原位置、透视和占比。"
: "产品应落在参考图当前主体所在区域,并围绕该区域同步重建新的场景内容。",
preserveSummary: isProductOnly
? "除产品主体外,文字排版、标题区、分割线、留白、背景、灯光和装饰都必须保留。"
: shouldProtectFallbackWhiteLayout
? "标题、单位、尺寸线、数字标注、白底留白、产品外轮廓、比例和透视必须保留。"
: shouldProtectFallbackReferencePosterAction
? "标题标签、人物数量、动作姿态、产品外轮廓、长宽比例、腿部结构、机位和版式必须保留。"
: "文字排版、标题区、分割线、留白和信息层级必须保留。",
styleSummary: isProductOnly
? "延续参考图原有风格、背景和氛围,不引入新的场景改造要求。"
: shouldProtectFallbackWhiteLayout
? "白底产品尺寸说明图,只允许产品抠图和尺寸标注,不引入室内场景。"
: shouldProtectFallbackReferencePosterAction
? "参考图动作海报语义优先,做同动作同版式类似图,前置词包必须完整重建原照片内容区里的背景、空间、软装和灯光。"
: fallbackHasPeopleAction
? "人物动作和关系保真,背景、产品场景、软装和灯光按前置词包明显重建。"
: shouldProtectFallbackLayout
? "延续参考图原有白底说明版式和图文语义,不引入新的空间场景。"
: shouldRebuildFallbackWhiteScene
? "按前置词包重建为白/浅色低饱和真实场景,保持干净浅色基调,不走深色复杂空间。"
: "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。",
avoidSummary: isProductOnly
? "不要改动背景、文案、按钮、图标、排版和装饰,不要重建整个场景,不要把多个产品角度同时塞进一张图。"
: shouldProtectFallbackWhiteLayout
? "不要生成厨房、餐椅、柜体、灯具、花瓶、人物或软装背景,不要改变产品形状,不要丢尺寸线和单位。"
: shouldProtectFallbackReferencePosterAction
? "不要把多人改成单人或无人,不要把坐姿/承重动作改成走动人物,不要改产品形状,不要原样保留参考图旧场景。"
: fallbackHasPeopleAction
? "不要生成白底单品图,不要删掉人物,不要把多人压成单人,不要只小幅复刻原图,也不要破坏文字版式。"
: shouldProtectFallbackLayout
? "不要把白底说明/强文案页场景化,不要新增厨房/客厅/黑墙/吊灯/复杂软装,不要让文字和图片失配。"
: shouldRebuildFallbackWhiteScene
? "不要只小幅复刻原图,不要把白场景改成深色厨房、黑墙、复杂吊灯或强软装,不要破坏原版式。"
: "不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。",
generationPrompt: params.shouldReplace
? isProductOnly
? `仅替换参考图中的产品主体,严格保留其余背景、文案、排版与光影关系。${params.reason}`
: isDetailReplacement && shouldProtectFallbackWhiteLayout
? `当前参考图按产品尺寸说明页处理:不使用前置词包;必须把产品主体抠出放在白底/米白底上,保留标题“产品尺寸”、单位 cm、尺寸箭头/尺寸线/数字标注和原版式位置;禁止生成室内场景、人物、椅子、柜体、灯具、花瓶或软装背景;产品形状、桌面比例、腿部结构、圆角和透视必须跟参考图一致。${params.reason}`
: isDetailReplacement && shouldProtectFallbackReferencePosterAction
? `当前参考图按动作海报处理:必须使用前置词包完整重建原照片内容区;根据参考图做同版式类似图,保留标题标签、人物数量、多人围坐/试坐/单侧承重/受力测试等动作、产品外轮廓、长宽比例、桌面圆角、腿部结构、透视角度和机位;同时按前置词包明显重建旧背景、空间、软装、灯光和产品场景,禁止走动人物、无人空场景、原图复刻或另一种产品形状。${params.reason}`
: isDetailReplacement && fallbackHasPeopleAction
? `当前参考图按人物/动作详情页处理:必须保留参考图中的人物数量、动作、朝向、互动关系和出镜比例,同时使用前置工作流词包明显重建图片框内旧背景、产品、空间、软装和灯光;禁止生成白底单品图或无人物空场景,保留文字区、版式骨架、机位和信息层级。${params.reason}`
: isDetailReplacement && shouldProtectFallbackLayout
? `保留参考图的版式、文案、白底/留白和图文语义,不使用前置词包;只在产品、材质、板材、原材料或视觉图片区做必要替换。${params.reason}`
: isDetailReplacement && shouldRebuildFallbackWhiteScene
? `当前参考图按白/浅色真实场景详情页处理:必须使用前置工作流词包明显重建图片框内旧背景、产品、空间、软装和灯光;同时保持白色、米白、浅灰、浅木色低饱和基调,保留参考图版式骨架、文字区、机位和信息层级。${params.reason}`
: isDetailReplacement
? `当前参考图按通用空间/场景详情页处理:必须使用前置工作流词包重建旧背景、空间、软装、灯光和产品场景,只保留参考图版式骨架、文字区、机位和信息层级。${params.reason}`
: `以工作流目标风格重建当前这一屏的场景,只保留版式、机位、文字区与留白结构。${params.reason}`
: params.reason,
analysisModelName: null,
analysisSource: "heuristic",
};
return applyDetailReplacementScenePlanGuards(scenePlan, {
modeType: params.modeType,
hasModelImage: params.hasModelImage,
hasImportantText: shouldProtectFallbackLayout,
containsPeople: false,
containsMultiplePeople:
fallbackHasPeopleAction || shouldProtectFallbackReferencePosterAction,
visualProfile: params.visualProfile,
replacementStrategy: params.replacementStrategy,
});
}
export function normalizeAgentModeReferences(
payload?: PostRequestBody["agentModePayload"],
): AgentReplacementModePayload["references"] {
if (!payload?.references?.length) return [];
return payload.references
.map((item) => {
const rerunReferenceImages = Array.isArray(item.rerunReferenceImages)
? item.rerunReferenceImages
.map((image) => normalizeAgentModeSourceImage(image))
.filter(
(image): image is NonNullable<ReturnType<typeof normalizeAgentModeSourceImage>> =>
Boolean(image)
)
.slice(0, 6)
: [];
return {
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,
passthrough: item.passthrough === true,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
...(rerunReferenceImages.length > 0 ? { rerunReferenceImages } : {}),
};
})
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
.sort((left, right) => left.sortOrder - right.sortOrder);
}
export function normalizeAgentModeSourceImage(value: unknown): NormalizedAgentModeSourceImage | null {
const record =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const url = String(record.url || "").trim();
if (!/^https?:\/\//i.test(url)) {
return null;
}
const label = String(record.label || record.name || "参考图").trim().slice(0, 255);
const name = String(record.name || record.label || label || "参考图").trim().slice(0, 255);
const assetId = String(record.assetId || "").trim() || null;
return {
url,
label: label || name || "参考图",
name: name || label || "参考图",
assetId,
};
}
export function normalizeAgentModeSourceImages(
payload?: PostRequestBody["agentModePayload"],
): {
productImages: NormalizedAgentModeSourceImage[];
modelImage: NormalizedAgentModeSourceImage | null;
} {
const sourceImages = payload?.sourceImages as AgentModeSourceImagePayload | undefined;
const productImages = Array.isArray(sourceImages?.productImages)
? sourceImages.productImages
.map((item) => normalizeAgentModeSourceImage(item))
.filter((item): item is NormalizedAgentModeSourceImage => Boolean(item))
.slice(0, 16)
: [];
return {
productImages,
modelImage: normalizeAgentModeSourceImage(sourceImages?.modelImage),
};
}
export function normalizeAgentModeDetailReplacementStrategy(
payload?: PostRequestBody["agentModePayload"],
): AgentDetailReplacementStrategy {
return normalizeDetailReplacementStrategy(payload?.replacementStrategy);
}
export function resolveImagePartsByReferenceExclusion(params: {
uploadedImageParts: AgentPresetMessageImagePart[];
referenceImageUrls: string[];
}) {
const referenceUrlSet = new Set(params.referenceImageUrls.map((url) => String(url || "").trim()));
const modelPart = params.uploadedImageParts.find((part) => /模特|model/i.test(String(part.name || "")));
const productParts = params.uploadedImageParts.filter((part) => {
const name = String(part.name || "").trim();
if (referenceUrlSet.has(part.url)) return false;
if (/^详情参考图|^SKU参考图/i.test(name)) return false;
if (modelPart && part.url === modelPart.url) return false;
return true;
});
return { productParts, modelPart: modelPart || null };
}
export async function resolveDirectReplacementModelRef(params: {
selectedChatModel?: string | null;
finalModelId?: string | null;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const explicitModel = String(params.selectedChatModel || "").trim();
if (explicitModel && explicitModel !== "auto") {
return explicitModel;
}
const routedModel = String(params.finalModelId || "").trim();
if (routedModel && routedModel !== "auto") {
return routedModel;
}
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const preferred =
imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
imageModels[0];
return preferred?.model.id || explicitModel || routedModel || "auto";
}
export function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
const normalized = String(value || "")
.trim()
.toLowerCase();
if (!normalized) {
return null;
}
if (
normalized === "true" ||
normalized === "yes" ||
normalized === "y" ||
normalized === "1" ||
normalized === "是"
) {
return true;
}
if (
normalized === "false" ||
normalized === "no" ||
normalized === "n" ||
normalized === "0" ||
normalized === "否"
) {
return false;
}
return null;
}
export function extractJsonObjectText(text: string): string | null {
const normalized = String(text || "").trim();
if (!normalized) {
return null;
}
const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fencedMatch?.[1]) {
const fencedContent = fencedMatch[1].trim();
if (fencedContent.startsWith("{") && fencedContent.endsWith("}")) {
return fencedContent;
}
}
if (normalized.startsWith("{") && normalized.endsWith("}")) {
return normalized;
}
const firstBraceIndex = normalized.indexOf("{");
const lastBraceIndex = normalized.lastIndexOf("}");
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
return normalized.slice(firstBraceIndex, lastBraceIndex + 1);
}
return null;
}
export function parseReplacementReferenceDecision(
text: string,
): Omit<ReplacementReferenceDecision, "detectionModelName" | "detectionSource"> | null {
const jsonText = extractJsonObjectText(text);
if (jsonText) {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const shouldReplace =
normalizeBooleanLike(parsed.shouldReplace) ??
normalizeBooleanLike(parsed.replace) ??
normalizeBooleanLike(parsed.isReplaceable);
const containsFurnitureProduct =
normalizeBooleanLike(parsed.containsFurnitureProduct) ??
normalizeBooleanLike(parsed.hasFurnitureProduct) ??
shouldReplace;
const reason = String(parsed.reason || parsed.summary || parsed.note || "")
.trim()
.slice(0, 80);
if (typeof shouldReplace === "boolean") {
return {
shouldReplace,
containsFurnitureProduct: Boolean(containsFurnitureProduct),
reason:
reason ||
(shouldReplace
? "检测到可替换的家具主体,继续执行替换"
: "未检测到可替换的家具主体,直接沿用参考图"),
};
}
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to parse replacement reference decision JSON:", error);
}
}
const normalized = String(text || "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
const shouldReplace = /shouldreplace["']?\s*[:=]\s*true|可替换|应该替换|存在可替换|检测到.*家具主体|yes/i.test(
normalized,
);
const shouldKeep = /shouldreplace["']?\s*[:=]\s*false|不应替换|不要替换|沿用参考图|未检测到.*家具主体|无可替换|not replace|no/i.test(
normalized,
);
if (!shouldReplace && !shouldKeep) {
return null;
}
return {
shouldReplace,
containsFurnitureProduct: shouldReplace,
reason: normalized.slice(0, 80),
};
}
export async function analyzeReplacementReferenceImage(params: {
imageUrl: string;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
modeType?: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
}): Promise<ReplacementReferenceDecision> {
const isDetailReplacement = params.modeType === "detail_replacement";
const isProductOnlyDetailReplacement =
isDetailReplacement && params.replacementStrategy === "product_only";
const detectionPrompt = isProductOnlyDetailReplacement
? [
"你是电商详情图产品主体替换判定器。",
"请判断这张详情参考图里,是否存在清晰、主要、适合被新产品替换的商品主体、产品展示区或产品照片区域。",
"详情替换里 shouldReplace 固定为 true;你的任务不是决定是否跳过,而是说明优先处理哪个产品/材质/视觉区域。",
"文字、标题、卖点标签、按钮、UI图标、分割线、品牌标识、留白和背景装饰不属于替换对象。",
"即使没有清晰产品主体,也要继续寻找产品展示区、材质/板材/原材料视觉区或可局部处理区域,不允许直接沿用参考图。",
'只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}',
].join("\n")
: isDetailReplacement
? [
"你是电商详情参考图背景替换判定器。",
"请判断这张图片里,是否存在适合被重建/替换的背景图、产品场景图、商品主体图或大面积视觉图片区。",
"详情替换里 shouldReplace 固定为 true;你的任务不是决定是否跳过,而是说明应优先处理产品、人物/模特、材质/板材/原材料、背景或视觉区。",
"这不是只找家具主体:只要存在照片、插画、场景背景、产品展示图区、人物展示图区、材质说明区,都必须继续处理。",
"必须保留的文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版不属于替换对象。",
"如果没有产品,检查是否需要模特/人物;如果没有人物,则根据上传图外观和参考图材质描述处理板材、材质、原材料、背景或视觉图片区;不允许直接沿用参考图。",
'只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}',
].join("\n")
: [
"你是家具商品参考图替换判定器。",
"请判断这张图片里,是否存在清晰、主要、适合被产品替换流程替换的家具商品主体。",
"家具主体包含:床、床垫、沙发、椅子、餐桌、茶几、柜子、电视柜、斗柜、床头柜、书桌等。",
"只有当家具商品是主要视觉主体、轮廓清晰、不是单纯环境陪衬时,shouldReplace 才能为 true。",
"以下情况必须判定 shouldReplace=false:纯文字页、参数说明页、品牌介绍页、材质/五金局部特写、只有模特没有家具、只有空间环境没有明确家具主体、家具很小只是背景、主体过多无法明确替换对象。",
'只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}',
].join("\n");
const configuredModels = params.models || (await getEnabledAiModelsWithProvider());
const configuredQwenVisionModel = configuredModels.find(
({ model }) =>
model.supportsVision &&
!model.isSubModel &&
QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`),
);
if (configuredQwenVisionModel) {
try {
const languageModel = await getLanguageModelById(configuredQwenVisionModel.model.id);
if (languageModel) {
const { text } = await generateText({
model: languageModel,
messages: [
{
role: "user",
content: [
{
type: "text",
text: detectionPrompt,
},
{
type: "image",
image: params.imageUrl,
},
],
},
],
temperature: 0,
maxTokens: 160,
abortSignal: AbortSignal.timeout(45_000),
} as any);
const parsed = parseReplacementReferenceDecision(text);
if (parsed) {
return {
...parsed,
detectionModelName:
configuredQwenVisionModel.model.displayName ||
configuredQwenVisionModel.model.modelId,
detectionSource: "configured_qwen3_vl",
};
}
}
} catch (error) {
chatApiLogger.warn("[Chat API] Configured Qwen-VL reference analysis failed:", error);
}
}
try {
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
const fallbackResult = await callQwen3Vl({
imageUrl: params.imageUrl,
prompt: detectionPrompt,
temperature: 0,
maxTokens: 160,
timeoutMs: 45_000,
retries: 1,
});
const parsed = parseReplacementReferenceDecision(fallbackResult.text);
if (parsed) {
return {
...parsed,
detectionModelName: fallbackResult.model,
detectionSource: "fallback_qwen3_vl",
};
}
} catch (error) {
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,
reason: "视觉判断失败,为避免误替换,直接沿用参考图",
detectionModelName: null,
detectionSource: "safe_passthrough",
};
}
export async function resolveReplacementScenePlanningRuntime(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): Promise<ReplacementScenePlanningRuntime> {
try {
const siteSettings = await getSiteSettings().catch(() => null);
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
siteSettingModelId: siteSettings?.canvasDetailAgentModelId,
});
if (localDetailAgentResolved) {
return {
kind: "local_moonshot",
model: localDetailAgentResolved.model,
modelName:
localDetailAgentResolved.modelRef.displayName ||
localDetailAgentResolved.modelRef.modelId,
};
}
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to resolve local Moonshot scene planning model:", error);
}
const configuredModels = params.models || (await getEnabledAiModelsWithProvider());
const configuredVisionModel =
configuredModels.find(
({ model }) =>
model.supportsVision &&
!model.isSubModel &&
QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`),
) ||
configuredModels.find(({ model }) => model.supportsVision && !model.isSubModel);
if (!configuredVisionModel) {
return null;
}
try {
const languageModel = await getLanguageModelById(configuredVisionModel.model.id);
if (!languageModel) {
return null;
}
return {
kind: "configured_vision",
model: languageModel,
modelName:
configuredVisionModel.model.displayName ||
configuredVisionModel.model.modelId,
};
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to resolve configured replacement scene planning model:", {
modelId: configuredVisionModel.model.id,
error,
});
return null;
}
}
export async function analyzeReplacementExecutionPlan(params: {
modeType?: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
referenceImageUrl: string;
referenceLabel: string;
productImageUrls: string[];
modelImageUrl?: string | null;
workflowPrompt?: string | null;
planningRuntime: ReplacementScenePlanningRuntime;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): Promise<ReplacementExecutionPlan> {
const isDetailReplacement = params.modeType === "detail_replacement";
const isProductOnlyDetailReplacement =
isDetailReplacement && params.replacementStrategy === "product_only";
const referenceVisualProfile = isDetailReplacement
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
: null;
if (params.planningRuntime) {
try {
const replacementScenePlanningTimeoutMs =
params.planningRuntime.kind === "local_moonshot" ? 90_000 : 45_000;
const analysisImageUrls = [params.referenceImageUrl];
const { inlineDataUrls } = await inlineVisionImages(analysisImageUrls, {
maxImages: 1,
maxSide: 900,
timeoutMs: 12000,
retries: 2,
loggerPrefix: "[Chat API] Replacement scene plan",
}).catch((error) => {
chatApiLogger.warn("[Chat API] Failed to inline replacement scene planning images:", error);
return { inlineDataUrls: [], skipped: [] };
});
const effectiveVisionImages =
inlineDataUrls.length > 0 ? inlineDataUrls : analysisImageUrls;
const workflowPromptExcerpt = isProductOnlyDetailReplacement
? ""
: String(params.workflowPrompt || "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 2200);
const analysisPrompt = [
isProductOnlyDetailReplacement
? "你是电商详情参考图中的“产品主体替换规划器”。"
: isDetailReplacement
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
: "你是电商详情替换流程中的“场景理解规划器”。",
`当前要分析的参考图:${params.referenceLabel}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
referenceVisualProfile
? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}`
: "",
referenceVisualProfile?.likelyWhiteLayout
? "像素初筛已经命中白底/浅色说明页候选:请继续读取画面文字和图像语义。若存在标题、标签、参数、尺寸、卖点、图标、白底图片区、圆角图片区或明显留白排版,generationRoute 必须是 layout_preserve_replace 或 copy_locked_regenerate,promptPackPolicy 必须 forbid;但如果这是多人围坐、聚餐、试坐、承重、受力测试等人物动作海报,generationRoute 必须是 people_relation_locked,promptPackPolicy 必须 soft:这里 soft 表示参考图锁动作和版式,但前置词包仍必须完整重建原照片内容区里的背景、空间、软装和灯光。"
: "",
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
: "",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "如果当前参考图存在人物展示需求,后续生图必须尽量复用提供模特图中的同一人物身份,不要明显换脸、换发型、换体型。"
: "",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "如果当前参考图是多人/群体/互动关系图,即使只提供 1 张模特图,也必须保留参考图的人数、相对位置和互动关系;提供的模特图作为主人物身份/气质参考,其余人物按参考图生成相近人群,不要把多人压成单人。"
: "",
isDetailReplacement
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
: "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级;如果画面里有产品主体/展示区,它也决定产品的拍摄角度、朝向、透视、可见面数量、占比和标注指向。",
isProductOnlyDetailReplacement
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
: isDetailReplacement
? "这张参考图可能是场景图、白底说明图、强文案图、人物关系图或纯版式图。你必须先判断它适合哪一种执行路由,再决定是否允许前置工作流词包参与。文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版必须保留。"
: "这张参考图不是要求原样保留的场景照片。原图里的沙发、床、柜体、墙面、地面、窗帘、灯具、摆件、桌面道具、背景家具和环境光氛围,都可以在后续生图阶段按前置工作流风格重建。",
isDetailReplacement
? "重要分流例子:如果标题/标签或画面写着“多人围坐、聚餐、横向受力测试、单侧承重、重心依旧平稳、桌体依然稳固”等动作海报语义,这不是纯锁版式页;必须 people_relation_locked + promptPackPolicy=soft。参考图决定人物数量、围坐/试坐/承重动作、产品受力关系和版式,前置词包必须完整重建原照片内容区里的背景、空间、软装、灯光和产品场景,避免原图复刻。"
: "",
isDetailReplacement
? "重要分流例子:如果标题或标注写着“产品尺寸、尺寸、单位 cm、160/180/210、90、75、尺寸线、尺寸箭头”等产品尺寸说明语义,必须 layout_preserve_replace + promptPackPolicy=forbid;后续应生成白底/米白底产品抠图和尺寸标注,不允许生成室内场景、椅子、人物、柜体、灯具或软装背景。"
: "",
"请完成以下任务:",
isProductOnlyDetailReplacement
? "1. shouldReplace 固定为 true:详情替换只允许规划处理范围,不允许判定直接沿用。若没有清晰产品主体,也要继续寻找产品展示区、材质/板材/原材料视觉区或可局部处理区域。"
: isDetailReplacement
? "1. shouldReplace 固定为 true:除非用户已经在前置流程人工勾选不处理(此处不会进入分析),你不能返回 false。若没有产品,检查人物/模特;没有人物,检查板材/材质/原材料、背景图或其它视觉区。"
: "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被替换的家具商品主体。",
"2. 判断 useModelImage,并说明原因。",
isDetailReplacement
? "2a. 先输出 pageKind,这是后续策略的核心分类:prompt_pack_scene=普通可吃词包空间/场景页;white_scene=白/浅色真实场景且可吃词包;white_layout=白底说明/纯排版/卖点参数页;copy_locked=文案和图强绑定页;people_scene=普通人物/模特真实场景,可保留人物并吃词包;action_poster=文字或画面明确要求多人围坐、自拍、聚餐、试坐、承重、受力测试等动作的海报页,参考图动作优先但场景图仍要按词包重建;dimension_spec=产品尺寸/单位/尺寸线/标注页;material_detail=材质/板材/原材料说明页。"
: "",
isDetailReplacement
? "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:纯白底参数/说明/卖点页、产品尺寸页、强图文绑定页必须 forbid;标题/标签已经明确多人围坐、聚餐、试坐、单侧承重、横向受力测试等动作的详情海报页必须 soft,soft 表示参考图锁动作和版式但前置词包仍完整重建背景/产品场景;通用空间场景必须 allow;普通真实人物生活场景可以 people_relation_locked + allow/soft。"
: "",
isDetailReplacement
? "4a. 白底/白场景分流规则:白底参数、规格、卖点、产品尺寸、强文案、图标说明、纯版式页必须 forbid;动作测试/多人围坐/聚餐/承重海报必须 soft 且场景图必须按词包完整重建;白/浅色真实场景页在没有强版式绑定时使用 soft/allow 词包重造图片框内产品、空间、软装和灯光。"
: "",
isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "",
isDetailReplacement
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "",
isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
: "",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。只要参考图有产品主体,generationPrompt 必须明确产品拍摄角度、朝向、透视和标注指向以参考图为准。"
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
isProductOnlyDetailReplacement
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
: isDetailReplacement
? "9. 如果 generationRoute 是 layout_preserve_replace 或 copy_locked_regenerate,sceneSummary、styleSummary 和 generationPrompt 必须优先服务参考图原有语义、版式和文案;如果 generationRoute 是 people_relation_locked 且 promptPackPolicy=allow/soft,则必须同时服务人物动作保真和前置词包场景改造。"
: "5. sceneSummary、styleSummary 和 generationPrompt 必须优先描述“按前置工作流重建后的目标场景”,而不是只复述参考图当前已有场景。",
isProductOnlyDetailReplacement
? "6. avoidSummary 里要明确避免改背景、改文字、改按钮、改图标、改版式、改留白、改装饰、改光影氛围。"
: isDetailReplacement
? "10. 如果前置工作流风格与当前参考图原场景冲突,promptPackPolicy=allow 时以前置词包为主;promptPackPolicy=soft 时以参考图人物/文案关系为骨架、以前置词包改造背景与产品场景;promptPackPolicy=forbid 时以参考图语义和版式为主。"
: "6. 如果前置工作流风格与当前参考图原场景冲突,必须保留版式骨架,但放弃原场景物件本身,以前置工作流指定的空间风格、软装、灯光和氛围为准。",
isProductOnlyDetailReplacement
? "7. 不要根据用户提示词或工作流风格改造场景,只服务于产品主体替换。"
: isDetailReplacement
? "11. avoidSummary 必须包含该路由下最容易失败的风险,例如:词图不匹配、文案变色、人物换脸、白底图被场景化、多人关系丢失。"
: "7. avoidSummary 里要明确避免“只替换产品、不重建场景”这类结果。",
workflowPromptExcerpt
? `当前风格与场景限制(来自前置工作流与模式模板):\n${workflowPromptExcerpt}`
: "",
"只返回严格 JSON,不要任何额外解释。",
`JSON 格式:
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","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");
const { text } = await generateText({
model: params.planningRuntime.model,
messages: [
{
role: "user",
content: [
{
type: "text",
text: analysisPrompt,
},
...effectiveVisionImages.map((image) => ({
type: "image" as const,
image,
})),
],
},
],
system:
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
maxTokens: 1200,
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
} as any);
const parsedDecision = parseReplacementReferenceDecision(text);
const parsedScenePlan = parseReplacementScenePlan(text, {
productImageCount: params.productImageUrls.length,
hasModelImage: isProductOnlyDetailReplacement
? false
: Boolean(params.modelImageUrl),
modeType: params.modeType,
replacementStrategy: params.replacementStrategy,
visualProfile: referenceVisualProfile,
});
if (parsedDecision && parsedScenePlan) {
const normalizedPlan = ensureDetailReferenceGenerates(
parsedDecision,
parsedScenePlan,
{
modeType: params.modeType,
referenceLabel: params.referenceLabel,
replacementStrategy: params.replacementStrategy,
},
);
return {
...normalizedPlan.decision,
detectionModelName: params.planningRuntime.modelName,
detectionSource:
params.planningRuntime.kind === "local_moonshot"
? "local_moonshot"
: "configured_vision",
scenePlan: {
...normalizedPlan.scenePlan,
analysisModelName: params.planningRuntime.modelName,
analysisSource:
params.planningRuntime.kind === "local_moonshot"
? "local_moonshot"
: "configured_vision",
},
};
}
} catch (error) {
chatApiLogger.warn("[Chat API] Replacement scene planning analysis failed:", {
referenceLabel: params.referenceLabel,
referenceImageUrl: params.referenceImageUrl,
error,
});
}
}
const fallbackDecision = await analyzeReplacementReferenceImage({
imageUrl: params.referenceImageUrl,
models: params.models,
modeType: params.modeType,
replacementStrategy: params.replacementStrategy,
});
const fallbackScenePlan = buildHeuristicReplacementScenePlan({
modeType: params.modeType,
productImageCount: params.productImageUrls.length,
hasModelImage: isProductOnlyDetailReplacement
? false
: Boolean(params.modelImageUrl),
shouldReplace: fallbackDecision.shouldReplace,
reason: fallbackDecision.reason,
replacementStrategy: params.replacementStrategy,
visualProfile: referenceVisualProfile,
});
const normalizedFallbackPlan = ensureDetailReferenceGenerates(
fallbackDecision,
fallbackScenePlan,
{
modeType: params.modeType,
referenceLabel: params.referenceLabel,
replacementStrategy: params.replacementStrategy,
},
);
return {
...normalizedFallbackPlan.decision,
scenePlan: normalizedFallbackPlan.scenePlan,
};
}
function shouldApplyPromptPackToDetailReplacement(params: {
replacementStrategy?: AgentDetailReplacementStrategy;
scenePlan?: ReplacementScenePlan | null;
}) {
if (params.replacementStrategy === "product_only") {
return false;
}
const scenePlan = params.scenePlan || null;
if (!scenePlan) {
return true;
}
if (scenePlan.promptPackPolicy === "forbid") {
return false;
}
if (
scenePlan.generationRoute === "direct_passthrough" ||
scenePlan.generationRoute === "layout_preserve_replace" ||
scenePlan.generationRoute === "copy_locked_regenerate"
) {
return scenePlan.promptPackPolicy === "allow";
}
if (scenePlan.generationRoute === "people_relation_locked") {
return true;
}
return true;
}
export function buildAgentReplacementPrompt(params: {
basePrompt: string;
modeType: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
marketplaceProductDetailReference?: boolean;
referenceLabel: string;
referenceIndex: number;
totalReferences: number;
hasModelImage: boolean;
productImageCount: number;
originalProductImageCount: number;
rerunReferenceCount?: number;
templateReferenceCount: number;
pairedProductIndex?: number;
scenePlan?: ReplacementScenePlan | null;
referenceGeometry?: ReplacementReferenceGeometry | null;
}) {
const imageOrderLines: string[] = [];
let nextImageIndex = 1;
const shouldApplyPromptPack =
params.modeType !== "detail_replacement" ||
shouldApplyPromptPackToDetailReplacement({
replacementStrategy: params.replacementStrategy,
scenePlan: params.scenePlan,
});
const detailScenePlanText = params.scenePlan
? planTextForPolicyGuards(params.scenePlan)
: "";
const isDimensionDetailPlan =
params.modeType === "detail_replacement" &&
(params.scenePlan?.pageKind === "dimension_spec" ||
DETAIL_DIMENSION_LAYOUT_HINT_PATTERN.test(detailScenePlanText));
const isReferencePosterActionPlan =
params.modeType === "detail_replacement" &&
(params.scenePlan?.pageKind === "action_poster" ||
DETAIL_REFERENCE_POSTER_ACTION_HINT_PATTERN.test(detailScenePlanText));
const isReferenceStabilityTestActionPlan =
isReferencePosterActionPlan &&
DETAIL_REFERENCE_STABILITY_TEST_ACTION_HINT_PATTERN.test(detailScenePlanText);
if (params.productImageCount > 0) {
const productRangeLabel =
params.productImageCount === 1
? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`;
imageOrderLines.push(
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略;如结果中需要生成商品主体,商品拍摄角度、朝向、透视和画面占比必须以最后一张参考图为准,不得直接沿用这些上传图的拍摄角度。`
);
nextImageIndex += params.productImageCount;
}
if (params.hasModelImage) {
imageOrderLines.push(
`图片${nextImageIndex}:用户提供的固定模特图,是人物身份锁定图;当前屏需要人物时,必须复用这张图里的同一人物(脸型五官、发型发色、体型肤色、穿搭风格保持一致)。如果参考图是多人,只把这张模特图作为主人物身份参考,其余人物按参考图的人数、关系和风格补齐。`
);
nextImageIndex += 1;
}
if (params.templateReferenceCount > 0) {
const templateRangeLabel =
params.templateReferenceCount === 1
? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + params.templateReferenceCount - 1}`;
imageOrderLines.push(
`${templateRangeLabel}:模板补充参考图,只能补充风格/材质氛围,不能覆盖最终参考图版式。`
);
nextImageIndex += params.templateReferenceCount;
}
const rerunReferenceCount = Math.max(0, Math.round(Number(params.rerunReferenceCount || 0)));
if (rerunReferenceCount > 0) {
const rerunRangeLabel =
rerunReferenceCount === 1
? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + rerunReferenceCount - 1}`;
imageOrderLines.push(
`${rerunRangeLabel}:本次重新生图的补充参考图,只用于理解用户新增的风格、构图、细节或局部调整方向;不得覆盖最终参考图底板的版式、文字层级和页面结构。`
);
nextImageIndex += rerunReferenceCount;
}
imageOrderLines.push(
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构;如果它包含产品主体/展示区,它同时决定产品姿态、朝向、透视、可见面数量、占比和标注指向。`
);
const marketplaceBrandRemovalLines =
params.modeType === "detail_replacement" && params.marketplaceProductDetailReference
? [
"【市场ID参考图去品牌规则】当前参考图来自外部市场商品详情,原图里可能包含第三方品牌名、Logo、店铺名、商标、水印、角标、平台标识或商家专属视觉符号;这些都属于污染层,最终结果必须去掉。",
"去品牌时只清理品牌/Logo/水印本身:保留原来的版式位置、留白、文字层级和信息结构,可把对应区域处理成干净留白、中性图形、无品牌装饰或与当前页面风格一致的空白占位;不要照抄或变体复刻原商标。",
"如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。",
]
: [];
const productAngleLockLines = [
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
];
const normalizedBasePrompt = String(params.basePrompt || "").trim();
const promptPackInstructionLines =
params.modeType === "detail_replacement" &&
shouldApplyPromptPack &&
normalizedBasePrompt
? [
"【必须生效的前置场景词包】",
normalizedBasePrompt,
isReferenceStabilityTestActionPlan
? "执行优先级:本屏是受力/承重测试海报,参考图锁人物排列、试坐/承重动作、产品受力关系、产品外形、版式、机位和文案层;只要原图包含照片/场景区域,前置场景词包就必须完整重建该区域里的旧背景、空间、软装、灯光、墙面、地面、摆件和产品场景,不能只轻微改色或复刻原图。"
: isReferencePosterActionPlan
? "执行优先级:前置场景词包必须改造原照片内容区里的旧背景、空间、软装、灯光、墙面、地面、摆件和产品场景;参考图只锁人物数量、围坐/聚餐/互动动作、产品使用关系、版式、机位、文案层和原照片区域,不锁旧背景物件。"
: "执行优先级:前置场景词包必须改造原照片内容区里的旧背景、空间、软装、灯光、墙面、地面、摆件和产品场景;参考图只锁版式、机位、文字层、主体位置和原照片区域,不锁旧背景物件。",
"不要把“原照片区域/圆角区域/裁切边界”画成可见白色矩形、线框、描边或新的卡片边框;这些只是版式范围,不是要生成的视觉元素。",
isReferenceStabilityTestActionPlan
? "如果最终图仍大量保留参考图原背景物件、原空间陈设或只做轻微修饰,判定为失败;如果人物测试动作、产品受力关系或产品形状被破坏,也判定为失败。"
: "如果最终图仍大量保留参考图原背景物件、原空间陈设或只做轻微修饰,判定为失败;必须能明显看出已按前置场景词包重建。",
]
: [];
const scenePlanLines = params.scenePlan
? [
"【本屏场景预分析】",
`页面类型:${params.scenePlan.pageKind}`,
`自动路由:${params.scenePlan.generationRoute}`,
`词包策略:${params.scenePlan.promptPackPolicy}`,
`文案策略:${params.scenePlan.textPolicy}`,
`人物策略:${params.scenePlan.peoplePolicy}`,
params.scenePlan.routeReason
? `路由理由:${params.scenePlan.routeReason}`
: "",
params.scenePlan.whiteBackgroundLikely
? shouldApplyPromptPack
? "白底/白场景判断:是,本屏按白/浅色真实场景处理;允许词包重造原照片内容区的背景、产品、空间、软装和灯光,但必须保留白/浅色低饱和基调、原版式层和文字层级;不要画新增边框。"
: "白底/白场景判断:是,禁止强行场景化;最终结果必须保持白底、浅色底或干净低饱和场景基调。"
: "",
params.scenePlan.copyImageCoupled
? "图文绑定判断:是,文案与画面语义必须保持匹配。"
: "",
params.scenePlan.sceneSummary
? `场景理解:${params.scenePlan.sceneSummary}`
: "",
params.scenePlan.cameraSummary
? `机位构图:${params.scenePlan.cameraSummary}`
: "",
params.scenePlan.placementSummary
? `主体位置:${params.scenePlan.placementSummary}`
: "",
params.scenePlan.preserveSummary
? `必须保留:${params.scenePlan.preserveSummary}`
: "",
params.scenePlan.styleSummary
? `风格约束:${params.scenePlan.styleSummary}`
: "",
params.scenePlan.avoidSummary
? `禁止事项:${params.scenePlan.avoidSummary}`
: "",
params.originalProductImageCount > params.productImageCount
? `原始共有 ${params.originalProductImageCount} 张产品角度图,本屏已筛选出最相关的 ${params.productImageCount} 张输入图;其余未入选产品图只是补充理解,不需要也不允许强行全部融入同一张结果图。`
: "",
params.scenePlan.productSelectionReason
? `产品图筛选理由:${params.scenePlan.productSelectionReason}`
: "",
params.scenePlan.useModelImage
? params.scenePlan.modelImageReason
? `本屏允许使用模特图:${params.scenePlan.modelImageReason}`
: "本屏允许使用模特图;只要最终画面出现人物,就必须使用用户提供的同一模特身份。"
: params.hasModelImage
? params.scenePlan.modelImageReason
? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}`
: "本屏不需要模特图,不允许把模特图强行融入结果。"
: "",
params.scenePlan.peoplePolicy === "preserve_relation" && params.hasModelImage
? "若当前参考图为多人/群体/互动关系图,即使只提供 1 张模特图,也必须保留参考图人数、相对位置、动作和关系;模特图作为主人物身份/气质参考,其余人物按参考图生成相近人群。"
: "",
params.scenePlan.peoplePolicy === "preserve_relation"
? "人物动作硬约束:如果当前参考图文字或画面包含自拍、合照、聚会、用餐、拿手机、使用产品、人与人互动等语义,生成结果必须出现对应人物数量、动作、朝向和互动关系;不允许生成无人空场景。"
: "",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "词包生效硬约束:本屏是受力/承重测试页,但只要参考图存在场景/照片内容区,就必须使用前置词包完整重建该区域里的旧背景、产品主体、软装、灯光、墙地面和空间氛围;不得只轻微改色、局部换物或原图复刻,也不得新增可见边框、白色线框或卡片描边。"
: "词包生效硬约束:本屏不能只做轻微改色、轻微换物或原图复刻;原照片内容区里的旧背景、产品主体、软装、灯光和空间氛围必须有清晰可见的重建痕迹,但不得新增可见边框、白色线框或卡片描边。"
: "",
params.scenePlan.generationPrompt
? `本屏生成方向:${params.scenePlan.generationPrompt}`
: "",
].filter(Boolean)
: [];
const productDetailInstructionLines =
params.replacementStrategy === "product_only"
? [
"【执行模式】详情替换 / 仅替换产品",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productAngleLockLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
"必须严格保留:背景、人物、手部姿态、桌面摆件、灯光氛围、阴影关系、文字排版、标题、卖点标签、按钮、UI图标、分割线、品牌标识、留白、画布比例和信息层级。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。",
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前产品区域。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张上传参考图可能是同一产品的多角度图,也可能是材质、板材、原材料或细节图;请综合理解结构、材质、颜色、比例、纹理、边角、开孔、五金与细节,不可只参考第一张。`
: params.productImageCount === 1
? "前面的上传参考图可能是产品图、材质图、板材图、原材料图或细节图,请准确理解它的结构、材质、颜色、比例、纹理和细节。"
: "本轮没有专门产品/材质输入图,请依据模特图、模板补充图以及最后一张参考图里的材质描述/视觉区做最小必要处理,不允许整屏直出。",
"你的任务是先识别参考图中的旧产品主体或产品展示区;如果没有明确产品,就继续识别材质/板材/原材料视觉区或可局部处理区域,并让处理结果与原版式自然融合。",
"不要重建背景,不要重画空间,不要调整文案,不要修改按钮、图标、标签、装饰元素,也不要改变人物、道具和环境光氛围。",
"如果参考图中没有清晰可替换的产品主体或产品展示区,也必须继续处理产品展示区、材质/板材/原材料视觉区或与上传图外观匹配的局部区域,不允许整屏直出。",
"不要把多个产品角度同时塞进一张结果图,不要新增无关主体,不要删除任何关键版式或 UI 元素。",
]
: [
"【执行模式】详情替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productAngleLockLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
isReferencePosterActionPlan
? isReferenceStabilityTestActionPlan
? "【参考受力测试硬约束】本屏必须吃前置词包完整重建场景图,但参考动作和产品结构优先:人物数量、相对位置、试坐/承重动作、产品受力关系、产品外轮廓、桌面长宽比例、圆角、腿部结构和透视角度必须跟参考图一致;新场景只能在原照片内容区内生成,不能新增白色线框或卡片描边。"
: "【参考动作海报硬约束】本屏必须吃前置词包完整重建场景图,但参考图动作和版式优先。若标题/标签写多人围坐、聚餐等,人物数量、相对位置、围坐/用餐/互动动作、产品使用关系、产品外轮廓、桌面长宽比例、圆角、腿部结构和透视角度都必须跟参考图一致;同时必须按前置词包明显重建原照片内容区里的旧背景、空间、软装、灯光和产品场景,不能只原图复刻。"
: "",
isDimensionDetailPlan
? "【产品尺寸图硬约束】本屏不使用前置词包;必须是白底/米白底产品抠图尺寸说明页。保留标题“产品尺寸”、单位 cm、尺寸线/箭头/数字标注和原版式位置;产品主体外轮廓、长宽比例、圆角、腿部结构和透视必须跟参考图一致;禁止生成室内场景、椅子、柜体、灯具、花瓶、人物或软装背景。"
: "",
"如果参考图是“上方文字/标签白底区 + 下方照片/场景圆角区域”的结构,必须原样保留标题区高度、文字换行、文字字号、文字左上角位置、标签位置、原照片区域起始高度、圆角、左右边距和上下留白;禁止把新场景铺到标题白底区背后,也禁止画出额外白框。",
"如果参考图里存在嵌入式照片区域、产品照片区、人物展示区或圆角大图区域,只能在原有照片内容区内重建背景/产品/人物画面;新画面必须遵守原有裁切范围,不能扩大照片区域,不能覆盖文字区、标签区或外层白底,也不能新增可见边框。",
"版式层优先级高于词包:前置词包只负责原照片内容区内部的空间风格、灯光、软装、背景和产品场景,不授权修改页面排版、文字位置、标签样式、留白比例或视觉层级。",
shouldApplyPromptPack
? "要把最后一张参考图理解为“页面版式底板 + 背景图/场景图区参考”,不是“旧场景照片必须原样保留”。"
: "本屏已自动判定为“参考图语义优先”,不要把最后一张参考图当成可随意重写的场景模板。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围和材质设定,必须用于完整重建当前受力测试页的照片/场景内容区;但不得覆盖参考图的受力测试语义、人物动作、产品结构和版式层。"
: "前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围、人群设定,必须作为当前这一屏的强制场景改造要求生效。"
: "本屏不使用前置工作流词包;如果词包里的空间、人群、灯光、风格与参考图冲突,必须忽略词包,以参考图当前图文语义为准。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "受力测试页的场景风格也必须优先执行前面用户提示词与工作流设定,不要被参考图原有旧场景绑死;参考图只锁测试动作、产品形状、版式骨架和机位。"
: "场景风格必须优先执行前面用户提示词与工作流设定中给出的空间风格、材质、灯光和氛围描述,不要被参考图原有旧场景绑死。"
: "不要把厨房、灯光、人群、主题风格等前置选择强行套入本屏;本屏只做必要的产品/视觉区替换和自然融合。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "执行时要基于这些场景组合要求,在原照片内容区内完整重建旧背景、空间、软装、灯光和产品场景,让当前这一屏呈现出与第一步已选风格一致的新场景画面;同时保持人物测试动作、产品受力关系、产品形状和白/浅色版式。"
: "执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。"
: "执行时必须保持参考图原有画面主题、文案语义、人物关系、构图和版式,只在可替换视觉区做有限替换。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "失败判定:如果最终图仍像参考图原场景、只轻微改色、只换产品、没有按前置词包重建背景/空间/软装/灯光,均视为失败;如果人物试坐/产品承重关系或产品形状被破坏,也视为失败。"
: "失败判定:如果最终图变成白底单品图、产品参数卡、单独商品抠图、或只是把原参考图小幅修饰,均视为失败;除非本屏已明确判定为白底参数/规格说明页。"
: "",
"允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图、材质/板材/原材料视觉区和必要的人物展示图区;不允许改动版式层。",
"如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。",
"最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。",
params.scenePlan?.textPolicy === "lock"
? "本屏文案已判定为强锁定:标题、卖点、参数、编号、标签文字的内容、颜色层级、位置和排版都不得改写、变色或拆成多种颜色。"
: "如果参考图里存在文字或标签,必须保持文字区位置和信息层级,不要擅自改写或重排。",
"文案存在性硬约束:如果参考图原本有标题、卖点、编号、参数、标签或图标说明,结果图必须仍然保留对应文本块的数量、相对位置、字号层级和颜色风格;禁止整段文案消失,禁止新增参考图没有的营销标签或小贴纸。",
params.scenePlan?.peoplePolicy === "require_user_model"
? "本屏包含人物但没有提供用户模特图:禁止把参考图中的人物身份当作用户模特迁移;只能保留人物数量、关系、动作和构图语义,不要换成不相关强人物主体。"
: params.scenePlan?.peoplePolicy === "preserve_relation"
? "本屏包含人物关系:必须保留人物数量、相对位置、互动关系、动作方向和出镜比例;如果参考图文字写了自拍/合照/多人围坐/聚餐/用餐/单侧承重/横向受力测试/拿手机等动作,必须让人物执行该动作,不要把多人图改成单人图、无人图或无关模特图。"
: "如果参考图没有明确人物展示需求,不要凭空新增人物主体。",
params.scenePlan?.whiteBackgroundLikely && shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "本屏是白/浅色受力测试页且词包已启用:必须重建原照片内容区里的旧背景、产品、软装、灯光和空间,同时保持外层白/浅色版式、文字区、人物测试动作、产品受力关系和产品形状;不要新增线框或卡片边框。"
: "本屏是白/浅色真实场景且词包已启用:必须让原照片内容区里的旧背景、产品、软装、灯光和空间发生清晰可见的重建;不能只微调原图或原样复刻,但也不能破坏外层白底/浅色版式和文字区;不要新增线框或卡片边框。"
: "",
params.scenePlan?.whiteBackgroundLikely && !shouldApplyPromptPack
? "本屏疑似白底/白场景/浅色说明图:禁止场景化、生活化或套空间词包,只允许保留白底、浅色底、干净留白、原图片框和原说明图语义并做必要的局部替换;最终不能变成厨房、客厅、黑墙、吊灯或复杂软装场景。"
: "",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。若参考图原文字靠边,也要在保持层级和相对位置的前提下向内收。",
"文字排版硬约束:标题和标签必须保持参考图原来的相对坐标、行数、行距、字号比例和对齐方式;不要把标题从白底区移动到照片区,不要把横排标签移动到角落,不要把两行标题改成三行或压成一行。",
"白底/白场景硬约束:如果最后一张参考图的背景是白底、米白、浅灰、浅木色或大面积干净留白,最终结果也必须是同类白/浅色场景;只能在原照片内容区或主体区域内替换产品/材质/人物动作,不允许生成深色厨房、客厅、吊灯、黑墙、复杂柜体或强软装背景。",
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
"照片区域硬约束:如果参考图下半部分是人物/产品照片区域,人物、产品和新场景只能出现在原照片内容区里;照片区域之外的标题白底、标签、页边距和装饰必须保持干净,不要被场景纹理、墙面、灯具或人物侵入,也不要绘制新增边框。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
shouldApplyPromptPack
? "前面的上传图和模特图用于生成新的背景图/产品场景图/材质视觉内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。"
: "前面的上传图用于必要的产品、材质、板材、原材料或视觉区替换;模特图只在本屏明确需要人物且策略允许时使用,不得覆盖参考图原有语义、版式和图文关系。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "可被替换重建的内容包括:原照片内容区里的背景照片、产品展示照片、板材、材质、原材料、墙面、地面材质、背景柜、层板、灯具、挂画、摆件、桌面道具、空间色调和环境光氛围;但这些新内容不能改变人物受力测试动作、产品外形、产品受力关系和版式层。"
: "可被替换重建的内容包括但不限于:背景照片、产品展示照片、板材、材质、原材料、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。"
: "可被替换的内容仅限参考图里明确的产品/材质/板材/原材料/视觉图片区;不要新增不属于参考图语义的空间、人物、道具、主题和文案。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张上传参考图可能是产品、多角度细节、材质、板材或原材料参考,请综合理解结构、材质、颜色、比例、纹理、边角、开孔、五金与细节,不可只参考第一张。`
: params.productImageCount === 1
? "前面的上传参考图可能是产品图、材质图、板材图、原材料图或细节图,请准确理解它的结构、材质、颜色、比例、纹理和细节。"
: "本轮没有专门产品/材质输入图,请依据模特图、模板补充图以及最后一张参考图里的材质描述/视觉区做最小必要处理,不允许整屏直出。",
shouldApplyPromptPack
? "请先识别最后一张参考图中需要被替换的背景图/场景图区域,尤其是原始照片内容区的裁切范围;只在同一版式骨架、同一原照片区域内生成新的背景画面,不要只做局部抠图式换产品,也不要把场景扩展到整张页面或画出新边框。"
: "请先识别最后一张参考图中真正允许替换的视觉区;如果只是白底说明、强文案页、多人关系图或图文匹配强的内容,不要强行重建整张场景。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置、人物测试动作、产品受力关系、产品形状和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感必须按第一步工作流设定进行明显重建。"
: "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。"
: "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置、人物关系和信息层级;不要因为前置词包改变这些内容。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "如果第一步工作流风格与受力测试参考图原场景冲突,必须保留版式骨架、测试语义、人物动作和产品结构,但放弃原背景图物件本身,以第一步工作流风格重建场景。"
: "如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。"
: "如果第一步工作流风格与参考图原场景冲突,必须保留参考图语义与版式,不要放弃原有图文关系。",
"前面的上传图用于提供商品主体、材质、板材、原材料、颜色、纹理和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致;如果参考图是多人但只提供 1 张模特图,保留参考图人数、关系、动作和站位,额外人物按参考图生成相近人群。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
shouldApplyPromptPack
? isReferenceStabilityTestActionPlan
? "如果生成结果仍然保留了参考图原有的大部分背景图物件,只是轻微调色或换产品,则视为执行失败;你必须让场景图按前置词包发生清晰可见的完整重建,同时保持承重动作和产品形状。"
: "如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。"
: "如果本屏已禁用词包,生成结果不应追求明显改造背景,而应追求图文匹配、文案稳定、人物关系稳定和局部替换自然。",
!shouldApplyPromptPack
? "版式说明页额外约束:不得新增参考图没有的标签、角标、贴纸、说明卡或营销文案;尺寸线、单位、参数和产品图片区要按原图关系保留。"
: "",
"绝对不要移动、删除、改写、重排参考图里的任何文字、UI图标、按钮、标签、装饰元素与排版元素。",
"输出结果必须保持最后一张参考图底板的原始纵横比与画布尺寸,不允许擅自改成长图、横图或裁掉任何版面区域。",
"如果参考图中没有产品主体,继续检查人物/模特展示;如果没有人物,则根据上传图外观和参考图中的材质描述处理板材、材质、原材料、背景或视觉图片区;不允许整屏直出。",
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式或UI元素。",
];
const skuInstructionLines = [
"【执行模式】SKU替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张 SKU 参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
...productAngleLockLines,
params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
: "请使用输入商品图作为本张参考图的一对一替换主体。",
params.productImageCount > 1
? "其余商品输入图同样是这个产品的补充角度/细节参考,请一并综合理解产品信息,不可忽略。"
: "输入商品图就是当前产品主体,请保持产品信息准确一致。",
"如果前面商品图与最后一张参考图存在冲突,始终以最后一张参考图的机位、场景、光影和构图为准。",
"请严格保留最后一张参考图的场景、机位、透视、光影和构图关系,只替换商品主体本身。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。`
: "输出必须保持当前参考图画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。",
"如果参考图中存在文字、标签或价格信息,必须完整保留在画布安全区内,不得贴边、出血、半截显示或被裁切。",
"只输出一张完整页面,不允许上下拼接两个方案、重复画面、长图瀑布流或明显横向分割线。",
"输出结果必须保持最后一张参考图的原始纵横比与画布尺寸。",
"输出要自然融合,不要改变参考图原有风格和景别。",
];
const instructionLines =
(params.modeType === "detail_replacement"
? productDetailInstructionLines
: skuInstructionLines).filter(Boolean);
if (params.hasModelImage) {
instructionLines.push(
"固定模特身份硬约束:如果本屏最终画面出现人物,必须使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换脸、换人、换发型或改成无关人物。多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
);
}
return [
params.modeType === "detail_replacement"
? ""
: params.basePrompt,
instructionLines.join("\n"),
]
.filter(Boolean)
.join("\n\n")
.trim();
}