ai-chatbot/app/(chat)/api/chat/_lib/detail-replacement.ts
2026-05-26 22:34:12 +08:00

1151 lines
51 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 { 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";
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) {
console.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 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 = {
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);
}
export function parseReplacementScenePlan(
text: string,
params: {
productImageCount: number;
hasModelImage: boolean;
},
): ReplacementScenePlan | null {
const jsonText = extractJsonObjectText(text);
if (!jsonText) {
return null;
}
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
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 = {
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.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.styleSummary,
]
.filter(Boolean)
.join(";")
.slice(0, 1200);
}
return scenePlan;
} catch (error) {
console.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";
return {
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) {
console.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) {
console.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) {
console.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) {
console.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) {
console.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) => {
console.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,并说明原因。",
"3. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述需要替换的背景/场景图片区、产品放置方式、模特使用规则,以及必须保留的文字/UI/图标/排版。"
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
isProductOnlyDetailReplacement
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
: "5. sceneSummary、styleSummary 和 generationPrompt 必须优先描述“按前置工作流重建后的目标场景”,而不是只复述参考图当前已有场景。",
isProductOnlyDetailReplacement
? "6. avoidSummary 里要明确避免改背景、改文字、改按钮、改图标、改版式、改留白、改装饰、改光影氛围。"
: "6. 如果前置工作流风格与当前参考图原场景冲突,必须保留版式骨架,但放弃原场景物件本身,以前置工作流指定的空间风格、软装、灯光和氛围为准。",
isProductOnlyDetailReplacement
? "7. 不要根据用户提示词或工作流风格改造场景,只服务于产品主体替换。"
: "7. avoidSummary 里要明确避免“只替换产品、不重建场景”这类结果。",
workflowPromptExcerpt
? `当前风格与场景限制(来自前置工作流与模式模板):\n${workflowPromptExcerpt}`
: "",
"只返回严格 JSON,不要任何额外解释。",
`JSON 格式:
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","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),
});
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) {
console.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,
}),
};
}
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;
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.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,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"要把最后一张参考图理解为“页面版式底板 + 背景图/场景图区参考”,不是“旧场景照片必须原样保留”。",
"前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围、人群设定,必须作为当前这一屏的强制场景改造要求生效。",
"场景风格必须优先执行前面用户提示词与工作流设定中给出的空间风格、材质、灯光和氛围描述,不要被参考图原有旧场景绑死。",
"执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。",
"允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图和必要的人物展示图区;不允许改动版式层。",
"如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。",
"最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
"前面的商品图和模特图用于生成新的背景图/产品场景图内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。",
"可被替换重建的内容包括但不限于:背景照片、产品展示照片、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
"请先识别最后一张参考图中需要被替换的背景图/场景图区域,并在同一版式骨架下生成新的背景画面;不要只做局部抠图式换产品。",
"请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。",
"如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。",
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
"如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。",
"绝对不要移动、删除、改写、重排参考图里的任何文字、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;
if (params.hasModelImage) {
instructionLines.push("如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致。");
}
return [
params.modeType === "detail_replacement" &&
params.replacementStrategy === "product_only"
? ""
: params.basePrompt,
instructionLines.join("\n"),
]
.filter(Boolean)
.join("\n\n")
.trim();
}