From 2814669881ceb0320f88d01f482fd49106354812 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 7 May 2026 22:28:35 +0800 Subject: [PATCH] fix: harden scene fusion replacement flow --- app/api/scene-fusion/route.ts | 352 +++++++++++++++++++++++++++---- lib/ai/providers/openai-image.ts | 24 ++- lib/ai/qwen3-vl.ts | 19 +- 3 files changed, 343 insertions(+), 52 deletions(-) diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index c8014c08f..7d5080b8f 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -61,8 +61,9 @@ const SCENE_FUSION_PROCESS_MAX_WORKERS = 1; const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000; const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; +const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; const SCENE_FUSION_STRICT_PROMPT_TAIL = - "硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;若识别不确定,请优先保持第二张图背景不变并完成主体替换。"; + "硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;结果中不得同时完整保留原目标主体与新产品;若识别不确定,请优先保持第二张图背景不变并完成主体替换。"; const SCENE_FUSION_RANDOM_BG_PROMPT_TAIL = "硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体边缘完整自然。"; const sceneFusionModelCooldown = new Map(); @@ -91,6 +92,8 @@ interface SceneFusionAgentMeta { sceneTargets: Array<{ id: string; targetName?: string; + positionHint?: string; + replaceCue?: string; source?: "user" | "agent"; confidence?: number; model?: string; @@ -137,15 +140,6 @@ function rotateCandidates(items: T[], startOffset: number): T[] { ]; } -function shuffleCandidates(items: T[]): T[] { - const shuffled = [...items]; - for (let i = shuffled.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; -} - function getNextSceneFusionRoundRobinOffset(poolSize: number): number { if (poolSize <= 0) return 0; const offset = sceneFusionRoundRobinCursor % poolSize; @@ -153,6 +147,58 @@ function getNextSceneFusionRoundRobinOffset(poolSize: number): number { return offset; } +function getSceneFusionCandidatePriority(candidate: SceneFusionModelCandidate): number { + const providerType = normalizeSceneFusionProviderType(candidate.provider.type); + const modelText = `${candidate.model.displayName} ${candidate.model.modelId}`.toLowerCase(); + + let score = 0; + + switch (providerType) { + case "openai-image": + score += 130; + break; + case "fal-image": + score += 120; + break; + case "alibaba-image": + score += 112; + break; + case "gemini": + score += 104; + break; + case "volcano-vision": + score += 96; + break; + case "ruxa-image": + score += 72; + break; + default: + score += 60; + break; + } + + score += candidate.model.supportsVision ? 28 : -18; + + if (candidate.model.imageGenConfig?.supports2K !== false) { + score += 4; + } + if (candidate.model.imageGenConfig?.supports4K !== false) { + score += 2; + } + + if (/nano-banana|ruxa/i.test(modelText)) { + score -= 18; + } + if (/seedream-5\.0/i.test(modelText)) { + score -= 4; + } + if (/edit|image[-\s]?edit|gpt[-\s]?image/i.test(modelText)) { + score += 6; + } + + return score; +} + function buildPreRmbgCandidateUrls(request: Request): string[] { const urls: string[] = []; const requestUrl = new URL(request.url); @@ -315,6 +361,26 @@ function normalizeSceneFusionName(value: unknown): string { return normalized.slice(0, 16); } +function normalizeSceneFusionHint(value: unknown, maxLength: number = 28): string { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + const normalized = raw + .replace(/[\r\n\t]+/g, " ") + .replace(/[“”"']/g, "") + .replace(/\s+/g, " ") + .trim(); + return normalized.slice(0, maxLength); +} + +function normalizeSceneFusionBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + const normalized = String(value ?? "").trim().toLowerCase(); + if (!normalized) return undefined; + if (["true", "yes", "y", "1", "是"].includes(normalized)) return true; + if (["false", "no", "n", "0", "否"].includes(normalized)) return false; + return undefined; +} + function extractJsonObject(text: string): Record | null { const content = String(text || "").trim(); if (!content) return null; @@ -367,37 +433,45 @@ async function detectProductNameWithAgent(imageUrl: string): Promise<{ return { name, confidence, model }; } -async function detectTargetNameWithAgent( - sceneImageUrl: string, - productNameHint: string -): Promise<{ +async function detectTargetNameWithAgent(params: { + productImageUrl: string; + sceneImageUrl: string; + productNameHint: string; +}): Promise<{ name: string; confidence: number; model: string; + positionHint?: string; + replaceCue?: string; } | null> { const prompt = [ "你是场景替换识别助手。", - `已知要替换进场景的产品是:${productNameHint || "该产品"}`, - "请识别当前场景图里最应该被替换的目标物体名称(与该产品品类最接近的场景主体)。", + `图1是待融合的产品参考图,产品名称提示:${params.productNameHint || "该产品"}`, + "图2是待处理的场景图。", + "请识别图2里最应该被图1产品替换的目标物体,并给出简短位置描述,帮助后续模型准确定位。", "要求:", "- 返回“完整物体名”,不要返回局部部件名", "- 名称精简,2-8个字(如:餐椅、沙发、茶几、花瓶)", - "- 如果无法确定,返回最主要主体物体名", + "- positionHint 用 4-16 个字描述目标大致位置(如:画面中央偏左、前景靠窗处、餐桌区域)", + "- replaceCue 用 4-20 个字描述该目标最醒目的识别特征(如:深色办公椅、木质圆餐桌)", + "- 如果无法确定,返回最主要主体物体名,并尽量给出位置", "仅返回JSON:", - '{\"targetName\":\"名称\",\"confidence\":0.0}', + '{\"targetName\":\"名称\",\"positionHint\":\"位置\",\"replaceCue\":\"识别特征\",\"confidence\":0.0}', ].join("\n"); const { text, model } = await callQwen3Vl({ - imageUrl: sceneImageUrl, + imageUrls: [params.productImageUrl, params.sceneImageUrl], prompt, temperature: 0.1, - maxTokens: 220, + maxTokens: 320, timeoutMs: SCENE_FUSION_AGENT_TIMEOUT_MS, retries: 1, }); const parsed = extractJsonObject(text); const name = normalizeSceneFusionName(parsed?.targetName); + const positionHint = normalizeSceneFusionHint(parsed?.positionHint, 20) || undefined; + const replaceCue = normalizeSceneFusionHint(parsed?.replaceCue, 26) || undefined; const confidenceRaw = Number(parsed?.confidence); const confidence = Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : 0.7; @@ -405,7 +479,116 @@ async function detectTargetNameWithAgent( return null; } - return { name, confidence, model }; + return { name, confidence, model, positionHint, replaceCue }; +} + +async function reviewSceneFusionResult(params: { + requestId: string; + sceneIndex: number; + sceneMode: SceneFusionMode; + productImageUrl: string; + sceneImageUrl: string; + generatedImageUrl: string; + productName?: string; + targetName?: string; + positionHint?: string; + replaceCue?: string; + modelName: string; +}): Promise<{ + passed: boolean; + score: number; + reason: string; + model: string; +} | null> { + if (params.sceneMode === "random-bg") { + return null; + } + + const targetLabel = params.targetName || "目标主体"; + const productLabel = params.productName || "产品"; + const locationHints = [params.positionHint, params.replaceCue].filter(Boolean).join(";"); + const prompt = [ + "你是电商场景替换质检助手。", + "图1是产品参考图,图2是原始场景图,图3是生成结果图。", + `目标任务:把图1中的${productLabel}替换进图2中${targetLabel}所在的位置。`, + locationHints ? `定位提示:${locationHints}` : "", + "请严格判断图3是否同时满足以下条件:", + `1. 图3里已经出现了与图1同主体的${productLabel}`, + `2. 图2原本需要被替换的${targetLabel}没有被完整保留下来`, + "3. 图2的大部分场景构图、机位和背景仍被保留,只做主体替换", + "4. 图3不是图1产品原图、裁切图或仅做轻微背景变化的回传图", + "若任一条件不满足,就判定为 failed。", + "仅返回 JSON:", + '{"passed":true,"score":0.0,"productMatched":true,"targetRemoved":true,"scenePreserved":true,"suspectedEcho":false,"reason":"一句话说明","confidence":0.0}', + ] + .filter(Boolean) + .join("\n"); + + try { + const { text, model } = await callQwen3Vl({ + imageUrls: [params.productImageUrl, params.sceneImageUrl, params.generatedImageUrl], + prompt, + temperature: 0.1, + maxTokens: 320, + timeoutMs: SCENE_FUSION_REVIEW_TIMEOUT_MS, + retries: 1, + }); + + const parsed = extractJsonObject(text); + if (!parsed) { + return null; + } + + const scoreRaw = Number(parsed.score); + const score = Number.isFinite(scoreRaw) ? Math.max(0, Math.min(1, scoreRaw)) : 0.5; + const passedFlag = normalizeSceneFusionBoolean(parsed.passed); + const productMatched = normalizeSceneFusionBoolean(parsed.productMatched); + const targetRemoved = normalizeSceneFusionBoolean(parsed.targetRemoved); + const scenePreserved = normalizeSceneFusionBoolean(parsed.scenePreserved); + const suspectedEcho = normalizeSceneFusionBoolean(parsed.suspectedEcho); + const reason = + normalizeSceneFusionHint(parsed.reason, 80) || + [ + productMatched === false ? "未识别出参考产品主体" : "", + targetRemoved === false ? "原目标主体仍明显保留" : "", + scenePreserved === false ? "原场景结构改动过大" : "", + suspectedEcho === true ? "结果疑似回传产品图" : "", + ] + .filter(Boolean) + .join(";") || + "质检结果不明确"; + + const passed = + passedFlag === true && + productMatched !== false && + targetRemoved !== false && + scenePreserved !== false && + suspectedEcho !== true && + score >= 0.55; + + console.log(`[SceneFusion][${params.requestId}] Review result:`, { + sceneIndex: params.sceneIndex + 1, + candidate: params.modelName, + passed, + score, + reason, + reviewModel: model, + }); + + return { + passed, + score, + reason, + model, + }; + } catch (error) { + console.warn(`[SceneFusion][${params.requestId}] Review skipped:`, { + sceneIndex: params.sceneIndex + 1, + candidate: params.modelName, + error: error instanceof Error ? error.message : "unknown", + }); + return null; + } } function extractStyleHintFromPrompt(customPrompt: string | undefined): string | undefined { @@ -567,6 +750,8 @@ async function enrichSceneLabelsWithAgent(params: { sceneTargets: clonedScenes.map((scene) => ({ id: scene.id, targetName: normalizeSceneFusionName(scene.targetName) || undefined, + positionHint: undefined, + replaceCue: undefined, source: normalizeSceneFusionName(scene.targetName) ? "user" : undefined, })), }; @@ -617,15 +802,18 @@ async function enrichSceneLabelsWithAgent(params: { const sceneIndex = unresolvedIndexes[current]!; const scene = clonedScenes[sceneIndex]!; try { - const targetDetected = await detectTargetNameWithAgent( - scene.imageUrl, - resolvedProductName || "产品" - ); + const targetDetected = await detectTargetNameWithAgent({ + productImageUrl: params.productImageUrl, + sceneImageUrl: scene.imageUrl, + productNameHint: resolvedProductName || "产品", + }); if (!targetDetected?.name) continue; scene.targetName = targetDetected.name; const metaScene = meta.sceneTargets.find((item) => item.id === scene.id); if (metaScene) { metaScene.targetName = targetDetected.name; + metaScene.positionHint = targetDetected.positionHint; + metaScene.replaceCue = targetDetected.replaceCue; metaScene.source = "agent"; metaScene.confidence = targetDetected.confidence; metaScene.model = targetDetected.model; @@ -684,18 +872,23 @@ function buildSceneFusionCandidatePool( return backendConfiguredImageModels; } + const prioritySorted = [...backendConfiguredImageModels].sort((left, right) => { + const scoreDiff = + getSceneFusionCandidatePriority(right) - getSceneFusionCandidatePriority(left); + if (scoreDiff !== 0) return scoreDiff; + return left.model.displayName.localeCompare(right.model.displayName, "zh-Hans-CN"); + }); + const preferred = preferredModelId - ? backendConfiguredImageModels.find((candidate) => candidate.model.id === preferredModelId) + ? prioritySorted.find((candidate) => candidate.model.id === preferredModelId) : undefined; if (!preferred) { - return shuffleCandidates(backendConfiguredImageModels); + return prioritySorted; } - const rest = backendConfiguredImageModels.filter( - (candidate) => candidate.model.id !== preferred.model.id - ); - return [preferred, ...shuffleCandidates(rest)]; + const rest = prioritySorted.filter((candidate) => candidate.model.id !== preferred.model.id); + return [preferred, ...rest]; } /** @@ -1161,6 +1354,8 @@ function buildPrompt( defaultPrompt: string, productName?: string, targetName?: string, + positionHint?: string, + replaceCue?: string, customPrompt?: string, sceneMode: SceneFusionMode = "scene-fission" ): string { @@ -1169,14 +1364,18 @@ function buildPrompt( if (sceneMode === "random-bg") { const subject = productName || "产品"; prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)${subject}外观、材质、颜色与细节保持一致;2)允许轻微调整主体位置与角度以匹配新背景;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`; - } else if (productName && targetName) { - // 如果用户提供了具体的产品名称和被替换物体名称,构建更精确的提示词 - // 使用"参考图1"的表述方式,并强调产品的视觉特征 - prompt = `请参考第一张图片中的${productName},将其自然地融合到第二张图片的场景中,替换掉场景中的${targetName}。要求:1)保持${productName}的整体外观、颜色、形状和设计风格与第一张图一致;2)调整${productName}的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`; - } else if (productName) { - prompt = `请参考第一张图片中的${productName},将其自然地融合到第二张图片的场景中。要求:1)保持${productName}的整体外观、颜色、形状和设计风格与第一张图一致;2)调整${productName}的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`; - } else if (targetName) { - prompt = `请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中,替换掉场景中的${targetName}。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`; + } else { + const subject = productName || "产品"; + const targetSubject = targetName || "场景主主体"; + const locationHints = [positionHint, replaceCue].filter(Boolean).join(","); + if (targetName) { + prompt = `请参考第一张图片中的${subject},仅替换第二张图片中的${targetSubject},不要改成全新场景。要求:1)保持${subject}的整体外观、材质、颜色与结构细节和第一张图一致;2)先移除第二张图中原有的${targetSubject},再将${subject}放入其原位置;3)调整${subject}的大小、角度、光影和透视,使其与第二张图自然融合;4)结果中不得同时完整保留原${targetSubject}和新的${subject}。`; + } else { + prompt = `请参考第一张图片中的${subject},将其自然地融合到第二张图片的场景主主体位置。要求:1)保持${subject}的整体外观、颜色、形状和设计风格与第一张图一致;2)优先替换第二张图中最主要的同类主体,不要只做风格微调;3)注意光影和透视关系使融合自然。`; + } + if (locationHints) { + prompt = `${prompt} 待替换目标定位:${locationHints}。`; + } } // 添加用户补充的关键词 @@ -1203,6 +1402,8 @@ async function processSceneWithProvider( sceneImageUrl: string, productName: string | undefined, targetName: string | undefined, + positionHint: string | undefined, + replaceCue: string | undefined, customPrompt: string | undefined, defaultPrompt: string, sceneMode: SceneFusionMode, @@ -1212,7 +1413,15 @@ async function processSceneWithProvider( imageSize?: string ): Promise<{ thumbnail: string; original: string; prompt: string }> { // 构建智能提示词 - const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode); + const prompt = buildPrompt( + defaultPrompt, + productName, + targetName, + positionHint, + replaceCue, + customPrompt, + sceneMode + ); console.log(`[SceneFusion] Built prompt: ${prompt}`); console.log(`[SceneFusion] Provider type: ${provider.type}`); @@ -1530,6 +1739,8 @@ async function processScene( sceneImageUrl: string, productName: string | undefined, targetName: string | undefined, + positionHint: string | undefined, + replaceCue: string | undefined, customPrompt: string | undefined, defaultPrompt: string, sceneMode: SceneFusionMode, @@ -1539,7 +1750,15 @@ async function processScene( size?: string ): Promise<{ thumbnail: string; original: string; prompt: string }> { // 构建智能提示词 - const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode); + const prompt = buildPrompt( + defaultPrompt, + productName, + targetName, + positionHint, + replaceCue, + customPrompt, + sceneMode + ); console.log(`[SceneFusion] Built prompt: ${prompt}`); @@ -1566,6 +1785,8 @@ async function processSceneWithDiversity( sceneImageUrl: string; productName?: string; targetName?: string; + positionHint?: string; + replaceCue?: string; customPrompt?: string; defaultPrompt: string; sceneMode: SceneFusionMode; @@ -1598,12 +1819,27 @@ async function processSceneWithDiversity( const healthyCandidates = rotatedCandidates.filter( (candidate) => !isSceneFusionCandidateCoolingDown(candidate) ); + const orderedHealthyCandidates = [...healthyCandidates].sort((left, right) => { + const scoreDiff = + getSceneFusionCandidatePriority(right) - getSceneFusionCandidatePriority(left); + if (scoreDiff !== 0) return scoreDiff; + return 0; + }); + const orderedFallbackCandidates = [...rotatedCandidates].sort((left, right) => { + const scoreDiff = + getSceneFusionCandidatePriority(right) - getSceneFusionCandidatePriority(left); + if (scoreDiff !== 0) return scoreDiff; + return 0; + }); const attemptCandidates = - healthyCandidates.length > 0 ? healthyCandidates : rotatedCandidates; + orderedHealthyCandidates.length > 0 ? orderedHealthyCandidates : orderedFallbackCandidates; console.log( `[SceneFusion][${params.requestId}] Scene#${params.sceneIndex + 1} candidate order: ${attemptCandidates - .map((candidate) => `${candidate.model.displayName}/${candidate.provider.type}`) + .map( + (candidate) => + `${candidate.model.displayName}/${candidate.provider.type}/score=${getSceneFusionCandidatePriority(candidate)}` + ) .join(" -> ")}` ); @@ -1627,6 +1863,8 @@ async function processSceneWithDiversity( params.sceneImageUrl, params.productName, params.targetName, + params.positionHint, + params.replaceCue, params.customPrompt, params.defaultPrompt, params.sceneMode, @@ -1656,6 +1894,25 @@ async function processSceneWithDiversity( throw new Error(`生成结果疑似回传产品图(${detail})`); } + const reviewResult = await reviewSceneFusionResult({ + requestId: params.requestId, + sceneIndex: params.sceneIndex, + sceneMode: params.sceneMode, + productImageUrl: params.productImageUrl, + sceneImageUrl: params.sceneImageUrl, + generatedImageUrl: result.original, + productName: params.productName, + targetName: params.targetName, + positionHint: params.positionHint, + replaceCue: params.replaceCue, + modelName: candidate.model.displayName, + }); + if (reviewResult && !reviewResult.passed) { + throw new Error( + `生成结果未通过替换质检(score=${reviewResult.score.toFixed(2)},${reviewResult.reason})` + ); + } + markSceneFusionCandidateHealthy(candidate); return { result, @@ -1918,6 +2175,8 @@ export async function POST(request: Request) { const scene = processedScenes[currentIndex]; if (!scene) break; const resolvedScene = resolvedSceneMap.get(scene.id) || scene; + const resolvedMetaScene = + enriched.meta.sceneTargets.find((item) => item.id === resolvedScene.id) || null; console.log( `[SceneFusion] ========== Processing scene ${currentIndex + 1}/${processedScenes.length} ==========` @@ -1932,6 +2191,11 @@ export async function POST(request: Request) { console.log( `[SceneFusion] Target Name: ${resolvedScene.targetName || "(not specified)"}` ); + if (resolvedMetaScene?.positionHint || resolvedMetaScene?.replaceCue) { + console.log( + `[SceneFusion] Target Hints: position=${resolvedMetaScene?.positionHint || "-"}, cue=${resolvedMetaScene?.replaceCue || "-"}` + ); + } console.log( `[SceneFusion] Custom Prompt: ${resolvedScene.customPrompt || "(none)"}` ); @@ -1942,6 +2206,8 @@ export async function POST(request: Request) { sceneImageUrl: resolvedScene.imageUrl, productName: resolvedProductName, targetName: resolvedScene.targetName, + positionHint: resolvedMetaScene?.positionHint, + replaceCue: resolvedMetaScene?.replaceCue, customPrompt: resolvedScene.customPrompt, defaultPrompt: config.defaultPrompt, sceneMode: resolvedSceneMode, diff --git a/lib/ai/providers/openai-image.ts b/lib/ai/providers/openai-image.ts index bdce58c0d..b583ab352 100644 --- a/lib/ai/providers/openai-image.ts +++ b/lib/ai/providers/openai-image.ts @@ -25,6 +25,7 @@ interface UploadedImage { const DEFAULT_IMAGE_SIZE = "2K"; const DEFAULT_TIMEOUT_MS = 300000; +const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400; const SUPPORTED_ASPECT_RATIOS = [ "1:1", "1:4", @@ -137,15 +138,28 @@ function resolveOpenAISize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") { const safeRatio = parseAspectRatioValue(aspectRatio); const base = baseMap[imageSize] || 1536; + const targetPixels = Math.max(base * base, OPENAI_IMAGE_MIN_TOTAL_PIXELS); + let width: number; + let height: number; if (safeRatio >= 1) { - const width = base; - const height = roundEven(base / safeRatio); - return `${width}x${height}`; + width = roundEven(Math.sqrt(targetPixels * safeRatio)); + height = roundEven(width / safeRatio); + } else { + height = roundEven(Math.sqrt(targetPixels / safeRatio)); + width = roundEven(height * safeRatio); + } + + while (width * height < targetPixels) { + if (safeRatio >= 1) { + width += 2; + height = roundEven(width / safeRatio); + } else { + height += 2; + width = roundEven(height * safeRatio); + } } - const height = base; - const width = roundEven(base * safeRatio); return `${width}x${height}`; } diff --git a/lib/ai/qwen3-vl.ts b/lib/ai/qwen3-vl.ts index e7d77a8be..349cdb9c4 100644 --- a/lib/ai/qwen3-vl.ts +++ b/lib/ai/qwen3-vl.ts @@ -41,7 +41,8 @@ async function getQwenImageProviderApiKey() { } export async function callQwen3Vl(params: { - imageUrl: string; + imageUrl?: string; + imageUrls?: string[]; prompt: string; temperature?: number; maxTokens?: number; @@ -52,6 +53,16 @@ export async function callQwen3Vl(params: { const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`; const timeoutMs = Math.max(10000, Math.min(params.timeoutMs ?? 45000, 120000)); const retries = Math.max(0, Math.min(params.retries ?? 1, 2)); + const imageUrls = [ + ...(Array.isArray(params.imageUrls) ? params.imageUrls : []), + params.imageUrl || "", + ] + .map((item) => String(item || "").trim()) + .filter(Boolean); + + if (imageUrls.length === 0) { + throw new Error("Qwen3-VL 调用缺少图片输入"); + } let lastError: Error | null = null; @@ -78,12 +89,12 @@ export async function callQwen3Vl(params: { role: "user", content: [ { type: "text", text: params.prompt }, - { + ...imageUrls.map((url) => ({ type: "image_url", image_url: { - url: params.imageUrl, + url, }, - }, + })), ], }, ],