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

2033 lines
94 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;
darkPixelRatio: number;
likelyWhiteLayout: 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 darkCount = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
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;
if (red >= 238 && green >= 238 && blue >= 238) {
nearWhiteCount += 1;
}
if (luminance >= 0.82 && saturationSpan <= 42) {
lightLowSaturationCount += 1;
}
if (luminance <= 0.2) {
darkCount += 1;
}
}
const nearWhitePixelRatio = nearWhiteCount / pixelCount;
const lightLowSaturationPixelRatio = lightLowSaturationCount / pixelCount;
const darkPixelRatio = darkCount / pixelCount;
const likelyWhiteLayout =
nearWhitePixelRatio >= 0.42 ||
(nearWhitePixelRatio >= 0.3 &&
lightLowSaturationPixelRatio >= 0.58 &&
darkPixelRatio <= 0.18);
return {
width: info.width,
height: info.height,
nearWhitePixelRatio,
lightLowSaturationPixelRatio,
darkPixelRatio,
likelyWhiteLayout,
reason: likelyWhiteLayout
? "像素初筛显示大面积白底/浅色低饱和区域,按白底说明图保护"
: "像素初筛未达到白底说明图阈值",
};
} 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 shouldCropTallComposite =
Boolean(targetAspectRatio && generatedAspectRatio) &&
Number(generatedAspectRatio) < Number(targetAspectRatio) * 0.78;
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 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 NormalizedAgentModeSourceImage = {
url: string;
label: string;
name: string;
assetId: string | null;
};
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 = {
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_WHITE_LAYOUT_HINT_PATTERN =
/白底|浅色底|大面积留白|产品说明|参数图|规格图|卖点图|标签排版|说明图|信息图|海报|版式图|排版页|文字页|标题页|编号|尺寸|规格|厘米|\bcm\b/i;
const DETAIL_STRONG_COPY_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,
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 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.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 alreadyAllowsPromptPack =
scenePlan.generationRoute === "prompt_pack_scene" &&
scenePlan.promptPackPolicy !== "forbid";
const hasWhiteLayoutHint = DETAIL_WHITE_LAYOUT_HINT_PATTERN.test(planText);
const hasPeopleActionHint = DETAIL_PEOPLE_ACTION_HINT_PATTERN.test(planText);
const shouldProtectWhiteLayout =
!isProductOnly &&
(scenePlan.whiteBackgroundLikely ||
params.visualProfile?.likelyWhiteLayout === true ||
hasWhiteLayoutHint);
const shouldProtectPeople =
!isProductOnly &&
(params.containsPeople ||
params.containsMultiplePeople ||
(!alreadyAllowsPromptPack && scenePlan.peoplePolicy !== "none") ||
hasPeopleActionHint);
const shouldLockCopy =
!isProductOnly &&
(scenePlan.copyImageCoupled ||
params.hasImportantText ||
scenePlan.textPolicy === "lock" ||
hasWhiteLayoutHint);
let next: ReplacementScenePlan = { ...scenePlan };
if (shouldProtectPeople) {
next = {
...next,
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,
generationRoute: shouldProtectWhiteLayout
? "layout_preserve_replace"
: "copy_locked_regenerate",
promptPackPolicy: "forbid",
textPolicy: "lock",
peoplePolicy: "none",
useModelImage: false,
routeReason: appendUniqueInstruction(
next.routeReason,
shouldProtectWhiteLayout
? "命中白底/说明图或强文案版式,禁止套用场景词包。"
: "命中强图文绑定,禁止套用场景词包。",
220,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
shouldProtectWhiteLayout
? "本屏按白底产品说明/参数排版处理:严格保留白底、留白、标题、标签、参数、文字颜色层级和产品位置,只做必要的产品/材质/视觉区替换,不允许生成室内场景、厨房、客厅、人物或软装背景。"
: "本屏文案与画面强绑定:文字内容、颜色、位置、层级和对应画面语义必须一致,只做必要局部替换,不允许按前置词包改造成另一套场景。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要把白底说明页场景化,不要新增厨房/客厅/人物/软装,不要让文案和图片失配,不要把文字拆成多种颜色。",
260,
),
whiteBackgroundLikely: next.whiteBackgroundLikely || shouldProtectWhiteLayout,
copyImageCoupled: true,
};
} else if (shouldProtectWhiteLayout) {
next = {
...next,
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,
};
}
if (params.visualProfile?.likelyWhiteLayout && !next.routeReason.includes("像素初筛")) {
next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220);
}
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 productImageSelection = buildDefaultProductImageSelection(params.productImageCount);
let scenePlan: ReplacementScenePlan = {
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 shouldProtectFallbackLayout =
isDetailReplacement &&
!isProductOnly &&
(Boolean(params.visualProfile?.likelyWhiteLayout) ||
DETAIL_STRONG_COPY_HINT_PATTERN.test(fallbackText));
const generationRoute = params.shouldReplace
? isProductOnly
? "layout_preserve_replace"
: isDetailReplacement
? shouldProtectFallbackLayout
? "layout_preserve_replace"
: "prompt_pack_scene"
: "prompt_pack_scene"
: "direct_passthrough";
const scenePlan: ReplacementScenePlan = {
generationRoute,
promptPackPolicy: inferPromptPackPolicy(generationRoute),
textPolicy:
generationRoute === "direct_passthrough" || shouldProtectFallbackLayout
? "preserve"
: "free",
peoplePolicy: "none",
whiteBackgroundLikely: Boolean(params.visualProfile?.likelyWhiteLayout),
copyImageCoupled: false,
routeReason: params.shouldReplace
? isProductOnly
? "兜底按仅替换产品执行,不使用词包。"
: isDetailReplacement
? shouldProtectFallbackLayout
? "兜底命中白底/说明/强文案特征,按详情版式保护执行,不使用词包。"
: "兜底未命中白底、强文案或真人互动硬约束,按通用场景图执行词包场景替换。"
: "兜底未检测到强约束,允许使用词包生成场景。"
: params.reason,
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
productSelectionReason: productImageSelection.productSelectionReason,
useModelImage: false,
modelImageReason: params.hasModelImage
? isProductOnly
? "仅替换产品模式不使用模特图,默认锁定参考图原有人物与场景。"
: "前置分析未明确当前参考图存在人物展示需求,兜底阶段默认不使用模特图。"
: "当前未提供模特图。",
sceneSummary: params.shouldReplace
? isProductOnly
? "严格保留参考图的场景、文案和版式,只把产品展示区中的旧产品替换成输入产品。"
: shouldProtectFallbackLayout
? "保留参考图版式骨架、构图、文字和主体位置,只做必要的产品或视觉区替换。"
: "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。"
: "当前参考图不满足替换条件。",
cameraSummary: "保持参考图原有机位、构图和景别,不做整体重画。",
placementSummary: isProductOnly
? "产品必须落在参考图原有产品所在区域,保持原位置、透视和占比。"
: "产品应落在参考图当前主体所在区域,并围绕该区域同步重建新的场景内容。",
preserveSummary: isProductOnly
? "除产品主体外,文字排版、标题区、分割线、留白、背景、灯光和装饰都必须保留。"
: "文字排版、标题区、分割线、留白和信息层级必须保留。",
styleSummary: isProductOnly
? "延续参考图原有风格、背景和氛围,不引入新的场景改造要求。"
: shouldProtectFallbackLayout
? "延续参考图原有版式和图文语义,不引入新的空间场景。"
: "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。",
avoidSummary: isProductOnly
? "不要改动背景、文案、按钮、图标、排版和装饰,不要重建整个场景,不要把多个产品角度同时塞进一张图。"
: shouldProtectFallbackLayout
? "不要把白底/说明/强文案页场景化,不要新增厨房/客厅/人物/软装,不要让文字和图片失配。"
: "不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。",
generationPrompt: params.shouldReplace
? isProductOnly
? `仅替换参考图中的产品主体,严格保留其余背景、文案、排版与光影关系。${params.reason}`
: isDetailReplacement && shouldProtectFallbackLayout
? `保留参考图的版式、文案、白底/留白和图文语义,不使用前置词包;只在产品、材质、板材、原材料或视觉图片区做必要替换。${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: false,
visualProfile: params.visualProfile,
replacementStrategy: params.replacementStrategy,
});
}
export function normalizeAgentModeReferences(
payload?: PostRequestBody["agentModePayload"],
): AgentReplacementModePayload["references"] {
if (!payload?.references?.length) return [];
return payload.references
.map((item) => ({
id: String(item.id || "").trim(),
label: String(item.label || "").trim() || "参考图",
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(item.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
: 0,
passthrough: item.passthrough === true,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
}))
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
.sort((left, right) => left.sortOrder - right.sortOrder);
}
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)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}。${referenceVisualProfile.reason}`
: "",
referenceVisualProfile?.likelyWhiteLayout
? "像素初筛已经命中白底/浅色说明页:如果画面是纯产品参数/说明/卖点排版且没有人物动作或真实使用展示,generationRoute 必须是 layout_preserve_replace 或 copy_locked_regenerate,promptPackPolicy 必须 forbid;如果画面包含人物、自拍、承重测试、使用产品、真实展示动作或明确生活场景,可以选择 people_relation_locked,并把 promptPackPolicy 设为 allow 或 soft。"
: "",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "如果当前参考图存在人物展示需求,后续生图必须尽量复用提供模特图中的同一人物身份,不要明显换脸、换发型、换体型。"
: "",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "如果当前参考图是多人/群体/互动关系图,即使只提供 1 张模特图,也必须保留参考图的人数、相对位置和互动关系;提供的模特图作为主人物身份/气质参考,其余人物按参考图生成相近人群,不要把多人压成单人。"
: "",
isDetailReplacement
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
: "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
isProductOnlyDetailReplacement
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
: isDetailReplacement
? "这张参考图可能是场景图、白底说明图、强文案图、人物关系图或纯版式图。你必须先判断它适合哪一种执行路由,再决定是否允许前置工作流词包参与。文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版必须保留。"
: "这张参考图不是要求原样保留的场景照片。原图里的沙发、床、柜体、墙面、地面、窗帘、灯具、摆件、桌面道具、背景家具和环境光氛围,都可以在后续生图阶段按前置工作流风格重建。",
"请完成以下任务:",
isProductOnlyDetailReplacement
? "1. shouldReplace 固定为 true:详情替换只允许规划处理范围,不允许判定直接沿用。若没有清晰产品主体,也要继续寻找产品展示区、材质/板材/原材料视觉区或可局部处理区域。"
: isDetailReplacement
? "1. shouldReplace 固定为 true:除非用户已经在前置流程人工勾选不处理(此处不会进入分析),你不能返回 false。若没有产品,检查人物/模特;没有人物,检查板材/材质/原材料、背景图或其它视觉区。"
: "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被替换的家具商品主体。",
"2. 判断 useModelImage,并说明原因。",
isDetailReplacement
? "3. 判断 generationRoute:prompt_pack_scene=通用场景图且可吃词包;layout_preserve_replace=锁定版式/白底/产品说明/材质说明,只做有限替换;copy_locked_regenerate=文案与画面强绑定,必须锁文案;people_relation_locked=多人/模特/互动关系图,必须锁人物关系。详情替换不允许 direct_passthrough。"
: "3. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。",
isDetailReplacement
? "4. 判断 promptPackPolicy:纯白底参数/说明/卖点页、强图文绑定且没有真实展示动作的详情页必须 forbid;通用空间场景必须 allow;人物/自拍/聚会/承重测试/使用产品等动作展示页可以 people_relation_locked + allow/soft,含义是保留人物动作与文字层级,但允许前置词包重建背景、灯光、软装和产品场景。"
: "",
isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "",
isDetailReplacement
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/模特互动/拿手机/用餐动作时必须 preserve_relation;如果文字明确写了“来张自拍、合照、聚会、用餐、互动”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "",
isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。"
: "",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,必须明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”;若 textPolicy=lock,必须明确文字内容、颜色、层级和位置不可改。"
: "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字","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;
referenceLabel: string;
referenceIndex: number;
totalReferences: number;
hasModelImage: boolean;
productImageCount: number;
originalProductImageCount: 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,
});
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;
}
imageOrderLines.push(
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构。`
);
const scenePlanLines = params.scenePlan
? [
"【本屏场景预分析】",
`自动路由:${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"
? "人物动作硬约束:如果当前参考图文字或画面包含自拍、合照、聚会、用餐、拿手机、使用产品、人与人互动等语义,生成结果必须出现对应人物数量、动作、朝向和互动关系;不允许生成无人空场景。"
: "",
params.scenePlan.generationPrompt
? `本屏生成方向:${params.scenePlan.generationPrompt}`
: "",
].filter(Boolean)
: [];
const productDetailInstructionLines =
params.replacementStrategy === "product_only"
? [
"【执行模式】详情替换 / 仅替换产品",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
"必须严格保留:背景、人物、手部姿态、桌面摆件、灯光氛围、阴影关系、文字排版、标题、卖点标签、按钮、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,
...scenePlanLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
shouldApplyPromptPack
? "要把最后一张参考图理解为“页面版式底板 + 背景图/场景图区参考”,不是“旧场景照片必须原样保留”。"
: "本屏已自动判定为“参考图语义优先”,不要把最后一张参考图当成可随意重写的场景模板。",
shouldApplyPromptPack
? "前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围、人群设定,必须作为当前这一屏的强制场景改造要求生效。"
: "本屏不使用前置工作流词包;如果词包里的空间、人群、灯光、风格与参考图冲突,必须忽略词包,以参考图当前图文语义为准。",
shouldApplyPromptPack
? "场景风格必须优先执行前面用户提示词与工作流设定中给出的空间风格、材质、灯光和氛围描述,不要被参考图原有旧场景绑死。"
: "不要把厨房、灯光、人群、主题风格等前置选择强行套入本屏;本屏只做必要的产品/视觉区替换和自然融合。",
shouldApplyPromptPack
? "执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。"
: "执行时必须保持参考图原有画面主题、文案语义、人物关系、构图和版式,只在可替换视觉区做有限替换。",
"允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图、材质/板材/原材料视觉区和必要的人物展示图区;不允许改动版式层。",
"如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。",
"最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。",
params.scenePlan?.textPolicy === "lock"
? "本屏文案已判定为强锁定:标题、卖点、参数、编号、标签文字的内容、颜色层级、位置和排版都不得改写、变色或拆成多种颜色。"
: "如果参考图里存在文字或标签,必须保持文字区位置和信息层级,不要擅自改写或重排。",
params.scenePlan?.peoplePolicy === "require_user_model"
? "本屏包含人物但没有提供用户模特图:禁止把参考图中的人物身份当作用户模特迁移;只能保留人物数量、关系、动作和构图语义,不要换成不相关强人物主体。"
: params.scenePlan?.peoplePolicy === "preserve_relation"
? "本屏包含人物关系:必须保留人物数量、相对位置、互动关系、动作方向和出镜比例;如果参考图文字写了自拍/合照/聚会/用餐/拿手机等动作,必须让人物执行该动作,不要把多人图改成单人图、无人图或无关模特图。"
: "如果参考图没有明确人物展示需求,不要凭空新增人物主体。",
params.scenePlan?.whiteBackgroundLikely && !shouldApplyPromptPack
? "本屏疑似白底/说明图:禁止场景化、生活化或套空间词包,只允许保留白底/说明图语义并做必要的局部替换。"
: "",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。若参考图原文字靠边,也要在保持层级和相对位置的前提下向内收。",
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
shouldApplyPromptPack
? "前面的上传图和模特图用于生成新的背景图/产品场景图/材质视觉内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。"
: "前面的上传图用于必要的产品、材质、板材、原材料或视觉区替换;模特图只在本屏明确需要人物且策略允许时使用,不得覆盖参考图原有语义、版式和图文关系。",
shouldApplyPromptPack
? "可被替换重建的内容包括但不限于:背景照片、产品展示照片、板材、材质、原材料、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。"
: "可被替换的内容仅限参考图里明确的产品/材质/板材/原材料/视觉图片区;不要新增不属于参考图语义的空间、人物、道具、主题和文案。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张上传参考图可能是产品、多角度细节、材质、板材或原材料参考,请综合理解结构、材质、颜色、比例、纹理、边角、开孔、五金与细节,不可只参考第一张。`
: params.productImageCount === 1
? "前面的上传参考图可能是产品图、材质图、板材图、原材料图或细节图,请准确理解它的结构、材质、颜色、比例、纹理和细节。"
: "本轮没有专门产品/材质输入图,请依据模特图、模板补充图以及最后一张参考图里的材质描述/视觉区做最小必要处理,不允许整屏直出。",
shouldApplyPromptPack
? "请先识别最后一张参考图中需要被替换的背景图/场景图区域,并在同一版式骨架下生成新的背景画面;不要只做局部抠图式换产品。"
: "请先识别最后一张参考图中真正允许替换的视觉区;如果只是白底说明、强文案页、多人关系图或图文匹配强的内容,不要强行重建整张场景。",
shouldApplyPromptPack
? "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。"
: "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置、人物关系和信息层级;不要因为前置词包改变这些内容。",
shouldApplyPromptPack
? "如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。"
: "如果第一步工作流风格与参考图原场景冲突,必须保留参考图语义与版式,不要放弃原有图文关系。",
"前面的上传图用于提供商品主体、材质、板材、原材料、颜色、纹理和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致;如果参考图是多人但只提供 1 张模特图,保留参考图人数、关系、动作和站位,额外人物按参考图生成相近人群。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
shouldApplyPromptPack
? "如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。"
: "如果本屏已禁用词包,生成结果不应追求明显改造背景,而应追求图文匹配、文案稳定、人物关系稳定和局部替换自然。",
"绝对不要移动、删除、改写、重排参考图里的任何文字、UI图标、按钮、标签、装饰元素与排版元素。",
"输出结果必须保持最后一张参考图底板的原始纵横比与画布尺寸,不允许擅自改成长图、横图或裁掉任何版面区域。",
"如果参考图中没有产品主体,继续检查人物/模特展示;如果没有人物,则根据上传图外观和参考图中的材质描述处理板材、材质、原材料、背景或视觉图片区;不允许整屏直出。",
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式或UI元素。",
];
const skuInstructionLines = [
"【执行模式】SKU替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张 SKU 参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
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" && !shouldApplyPromptPack
? ""
: params.basePrompt,
instructionLines.join("\n"),
]
.filter(Boolean)
.join("\n\n")
.trim();
}