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

1544 lines
68 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 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 normalizeReplacementResultImage(params: {
generatedImageUrl: string;
generatedThumbnailUrl?: string;
referenceImageUrl: string;
referenceGeometry?: ReplacementReferenceGeometry | 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;
if (!targetWidth || !targetHeight) {
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.metadata();
targetWidth = referenceMetadata.width;
targetHeight = referenceMetadata.height;
}
if (!targetWidth || !targetHeight) {
throw new Error("无法读取参考图尺寸");
}
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.metadata();
if (generatedMetadata.width === targetWidth && generatedMetadata.height === targetHeight) {
return {
originalUrl: params.generatedImageUrl,
thumbnailUrl: params.generatedThumbnailUrl || params.generatedImageUrl,
imageSize: `${targetWidth}x${targetHeight}`,
};
}
const normalizedBuffer = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, {
// Keep the model output proportional when adapting it back to the reference page.
// `fill` stretches the picture and can squash products; `cover` behaves like a screenshot crop.
fit: "cover",
position: "centre",
withoutEnlargement: false,
})
.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: `${targetWidth}x${targetHeight}`,
};
}
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;
};
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";
}
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.whiteBackgroundLikely) {
return "layout_preserve_replace";
}
if (params.copyImageCoupled || params.hasImportantText) {
return "copy_locked_regenerate";
}
if (params.containsMultiplePeople || params.containsPeople) {
return "people_relation_locked";
}
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 params.hasModelImage ? "preserve_relation" : "require_user_model";
}
export function parseReplacementScenePlan(
text: string,
params: {
productImageCount: number;
hasModelImage: boolean;
replacementStrategy?: AgentDetailReplacementStrategy;
},
): 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 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: Array.from(
{ length: Math.min(Math.max(params.productImageCount, 1), 2) },
(_, index) => index + 1,
),
productSelectionReason:
params.productImageCount > 1
? "默认优先使用前两张产品图作为主视角与补充细节,其余角度不强制同时融入。"
: "默认使用当前唯一产品图作为主体参考。",
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);
}
return scenePlan;
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to parse replacement scene plan JSON:", error);
return null;
}
}
export function buildHeuristicReplacementScenePlan(params: {
productImageCount: number;
hasModelImage: boolean;
shouldReplace: boolean;
reason: string;
replacementStrategy?: AgentDetailReplacementStrategy;
}): ReplacementScenePlan {
const isProductOnly = params.replacementStrategy === "product_only";
const generationRoute = params.shouldReplace
? isProductOnly
? "layout_preserve_replace"
: "prompt_pack_scene"
: "direct_passthrough";
return {
generationRoute,
promptPackPolicy: inferPromptPackPolicy(generationRoute),
textPolicy: generationRoute === "direct_passthrough" ? "preserve" : "free",
peoplePolicy: "none",
whiteBackgroundLikely: false,
copyImageCoupled: false,
routeReason: params.shouldReplace
? isProductOnly
? "兜底按仅替换产品执行,不使用词包。"
: "兜底未检测到强约束,允许使用词包生成场景。"
: params.reason,
selectedProductImageIndexes: Array.from(
{ length: Math.min(Math.max(params.productImageCount, 1), 2) },
(_, index) => index + 1,
),
productSelectionReason:
params.productImageCount > 1
? "默认优先使用前两张产品图作为主视角与补充细节,其余角度不强制同时融入。"
: "默认使用当前唯一产品图作为主体参考。",
useModelImage: false,
modelImageReason: params.hasModelImage
? isProductOnly
? "仅替换产品模式不使用模特图,默认锁定参考图原有人物与场景。"
: "前置分析未明确当前参考图存在人物展示需求,兜底阶段默认不使用模特图。"
: "当前未提供模特图。",
sceneSummary: params.shouldReplace
? isProductOnly
? "严格保留参考图的场景、文案和版式,只把产品展示区中的旧产品替换成输入产品。"
: "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。"
: "当前参考图不满足替换条件。",
cameraSummary: "保持参考图原有机位、构图和景别,不做整体重画。",
placementSummary: isProductOnly
? "产品必须落在参考图原有产品所在区域,保持原位置、透视和占比。"
: "产品应落在参考图当前主体所在区域,并围绕该区域同步重建新的场景内容。",
preserveSummary: isProductOnly
? "除产品主体外,文字排版、标题区、分割线、留白、背景、灯光和装饰都必须保留。"
: "文字排版、标题区、分割线、留白和信息层级必须保留。",
styleSummary: isProductOnly
? "延续参考图原有风格、背景和氛围,不引入新的场景改造要求。"
: "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。",
avoidSummary: isProductOnly
? "不要改动背景、文案、按钮、图标、排版和装饰,不要重建整个场景,不要把多个产品角度同时塞进一张图。"
: "不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。",
generationPrompt: params.shouldReplace
? isProductOnly
? `仅替换参考图中的产品主体,严格保留其余背景、文案、排版与光影关系。${params.reason}`
: `以工作流目标风格重建当前这一屏的场景,只保留版式、机位、文字区与留白结构。${params.reason}`
: params.reason,
analysisModelName: null,
analysisSource: "heuristic",
};
}
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
? [
"你是电商详情图产品主体替换判定器。",
"请判断这张详情参考图里,是否存在清晰、主要、适合被新产品替换的商品主体、产品展示区或产品照片区域。",
"你的任务不是重建整张场景,也不是替换文字排版;只判断是否有明确的产品替换目标。",
"文字、标题、卖点标签、按钮、UI图标、分割线、品牌标识、留白和背景装饰不属于替换对象。",
"只有当图片几乎是纯文字页、参数表、品牌介绍页、没有明确产品展示区时,shouldReplace 才能为 false。",
'只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}',
].join("\n")
: isDetailReplacement
? [
"你是电商详情参考图背景替换判定器。",
"请判断这张图片里,是否存在适合被重建/替换的背景图、产品场景图、商品主体图或大面积视觉图片区。",
"这不是只找家具主体:只要存在照片、插画、场景背景、产品展示图区、人物展示图区,都可以 shouldReplace=true。",
"必须保留的文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版不属于替换对象。",
"只有当图片几乎是纯文字页、参数表、品牌介绍页、无明显背景图/场景图区/商品图片区时,shouldReplace 才能为 false。",
'只输出 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);
}
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";
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}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "如果当前参考图存在人物展示需求,后续生图必须尽量复用提供模特图中的同一人物身份,不要明显换脸、换发型、换体型。"
: "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
isProductOnlyDetailReplacement
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
: isDetailReplacement
? "这张参考图可能是场景图、白底说明图、强文案图、人物关系图或纯版式图。你必须先判断它适合哪一种执行路由,再决定是否允许前置工作流词包参与。文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版必须保留。"
: "这张参考图不是要求原样保留的场景照片。原图里的沙发、床、柜体、墙面、地面、窗帘、灯具、摆件、桌面道具、背景家具和环境光氛围,都可以在后续生图阶段按前置工作流风格重建。",
"请完成以下任务:",
isProductOnlyDetailReplacement
? "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被新产品替换的商品主体、产品照片区域或产品展示区。纯文字/纯参数/纯品牌介绍且没有产品展示区时才为 false。"
: isDetailReplacement
? "1. 判断 shouldReplace:当前参考图里是否存在适合被替换/重建的背景图、产品场景图、商品主体图、人物展示图或大面积视觉图片区。纯文字/纯参数/纯品牌介绍且没有视觉图片区时才为 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:只有通用空间场景且没有强文案、白底、多人关系、词图强绑定限制时才 allow;白底图、强文案图、多人关系图、图文匹配强的详情页必须 forbid。"
: "",
isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "",
isDetailReplacement
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/模特互动时必须 preserve_relation;如果没有提供用户模特图但参考图有人物身份,不允许把参考人物身份当成用户模特直接迁移。"
: "",
isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。"
: "",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若 textPolicy=lock,必须明确文字内容、颜色、层级和位置不可改;若 peoplePolicy=preserve_relation,必须明确人物数量、关系、动作和身份约束。"
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
isProductOnlyDetailReplacement
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
: isDetailReplacement
? "9. 如果 generationRoute 不是 prompt_pack_scene,sceneSummary、styleSummary 和 generationPrompt 必须优先服务参考图原有语义、版式、文案和人物关系,不要强行套前置词包。"
: "5. sceneSummary、styleSummary 和 generationPrompt 必须优先描述“按前置工作流重建后的目标场景”,而不是只复述参考图当前已有场景。",
isProductOnlyDetailReplacement
? "6. avoidSummary 里要明确避免改背景、改文字、改按钮、改图标、改版式、改留白、改装饰、改光影氛围。"
: isDetailReplacement
? "10. 如果前置工作流风格与当前参考图原场景冲突,只有 promptPackPolicy=allow 时才以前置词包为主;其他情况必须以参考图语义和版式为主。"
: "6. 如果前置工作流风格与当前参考图原场景冲突,必须保留版式骨架,但放弃原场景物件本身,以前置工作流指定的空间风格、软装、灯光和氛围为准。",
isProductOnlyDetailReplacement
? "7. 不要根据用户提示词或工作流风格改造场景,只服务于产品主体替换。"
: isDetailReplacement
? "11. avoidSummary 必须包含该路由下最容易失败的风险,例如:词图不匹配、文案变色、人物换脸、白底图被场景化、多人关系丢失。"
: "7. avoidSummary 里要明确避免“只替换产品、不重建场景”这类结果。",
workflowPromptExcerpt
? `当前风格与场景限制(来自前置工作流与模式模板):\n${workflowPromptExcerpt}`
: "",
"只返回严格 JSON,不要任何额外解释。",
`JSON 格式:
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","generationRoute":"prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`,
]
.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),
replacementStrategy: params.replacementStrategy,
});
if (parsedDecision && parsedScenePlan) {
return {
...parsedDecision,
detectionModelName: params.planningRuntime.modelName,
detectionSource:
params.planningRuntime.kind === "local_moonshot"
? "local_moonshot"
: "configured_vision",
scenePlan: {
...parsedScenePlan,
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,
});
return {
...fallbackDecision,
scenePlan: buildHeuristicReplacementScenePlan({
productImageCount: params.productImageUrls.length,
hasModelImage: isProductOnlyDetailReplacement
? false
: Boolean(params.modelImageUrl),
shouldReplace: fallbackDecision.shouldReplace,
reason: fallbackDecision.reason,
replacementStrategy: params.replacementStrategy,
}),
};
}
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" ||
scenePlan.generationRoute === "people_relation_locked"
) {
return scenePlan.promptPackPolicy === "allow";
}
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
? "白底/说明图判断:是,禁止强行场景化。"
: "",
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.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},输出必须保持这个页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前产品区域。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
"你的任务只有一个:识别参考图中的旧产品主体或产品展示区,用输入产品替换它,并让新产品与原场景自然融合。",
"不要重建背景,不要重画空间,不要调整文案,不要修改按钮、图标、标签、装饰元素,也不要改变人物、道具和环境光氛围。",
"如果参考图中没有清晰可替换的产品主体或产品展示区,应直接沿用参考图,不要硬生成。",
"不要把多个产品角度同时塞进一张结果图,不要新增无关主体,不要删除任何关键版式或 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
? "本屏疑似白底/说明图:禁止场景化、生活化或套空间词包,只允许保留白底/说明图语义并做必要的局部替换。"
: "",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
shouldApplyPromptPack
? "前面的商品图和模特图用于生成新的背景图/产品场景图内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。"
: "前面的商品图用于必要的产品/视觉区替换;模特图只在本屏明确需要人物且策略允许时使用,不得覆盖参考图原有语义、版式和图文关系。",
shouldApplyPromptPack
? "可被替换重建的内容包括但不限于:背景照片、产品展示照片、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。"
: "可被替换的内容仅限参考图里明确的产品/视觉图片区;不要新增不属于参考图语义的空间、人物、道具、主题和文案。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
shouldApplyPromptPack
? "请先识别最后一张参考图中需要被替换的背景图/场景图区域,并在同一版式骨架下生成新的背景画面;不要只做局部抠图式换产品。"
: "请先识别最后一张参考图中真正允许替换的视觉区;如果只是白底说明、强文案页、多人关系图或图文匹配强的内容,不要强行重建整张场景。",
shouldApplyPromptPack
? "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。"
: "请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置、人物关系和信息层级;不要因为前置词包改变这些内容。",
shouldApplyPromptPack
? "如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。"
: "如果第一步工作流风格与参考图原场景冲突,必须保留参考图语义与版式,不要放弃原有图文关系。",
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
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();
}