From 4a55cc1b0fabddbab2cf724a8588ce2c27d919c0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 19:56:57 +0800 Subject: [PATCH] fix(scene-fusion): diversify model pool and harden stream failover --- app/api/scene-fusion/route.ts | 97 ++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index a7379464d..5494b8da9 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -38,12 +38,14 @@ const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地 const SCENE_FUSION_MODEL_COOLDOWN_MS = 2 * 60 * 1000; const SCENE_FUSION_MAX_ITEMS = 10; const sceneFusionModelCooldown = new Map(); +let sceneFusionRoundRobinCursor = 0; interface SceneFusionModelCandidate { model: { id: string; modelId: string; displayName: string; + supportsVision: boolean; }; provider: { type: string; @@ -119,6 +121,13 @@ function shuffleCandidates(items: T[]): T[] { return shuffled; } +function getNextSceneFusionRoundRobinOffset(poolSize: number): number { + if (poolSize <= 0) return 0; + const offset = sceneFusionRoundRobinCursor % poolSize; + sceneFusionRoundRobinCursor = (sceneFusionRoundRobinCursor + 1) % Number.MAX_SAFE_INTEGER; + return offset; +} + function buildSceneFusionCandidatePool( allModels: Awaited>, preferredModelId: string @@ -127,7 +136,6 @@ function buildSceneFusionCandidatePool( .filter((entry) => { if (!entry?.model?.isEnabled) return false; if (!entry?.model?.supportsImageGen) return false; - if (!entry?.model?.supportsVision) return false; if (entry?.model?.isSubModel) return false; return true; }) @@ -136,6 +144,7 @@ function buildSceneFusionCandidatePool( id: String(entry.model.id), modelId: String(entry.model.modelId || ""), displayName: String(entry.model.displayName || entry.model.modelId || entry.model.id), + supportsVision: Boolean(entry.model.supportsVision), }, provider: { type: normalizeSceneFusionProviderType(String(entry.provider.type || "")), @@ -803,6 +812,8 @@ async function processSceneWithDiversity( aspectRatio?: string; imageSize?: string; sceneIndex: number; + requestOffset: number; + requestId: string; candidates: SceneFusionModelCandidate[]; } ): Promise<{ @@ -817,7 +828,10 @@ async function processSceneWithDiversity( throw new Error("后台已配置生图模型中暂无可用于场景裂变的供应商,请检查模型供应商类型"); } - const rotatedCandidates = rotateCandidates(supportedCandidates, params.sceneIndex); + const rotatedCandidates = rotateCandidates( + supportedCandidates, + params.requestOffset + params.sceneIndex + ); pruneSceneFusionCooldown(); const healthyCandidates = rotatedCandidates.filter( @@ -826,6 +840,12 @@ async function processSceneWithDiversity( const attemptCandidates = healthyCandidates.length > 0 ? healthyCandidates : rotatedCandidates; + console.log( + `[SceneFusion][${params.requestId}] Scene#${params.sceneIndex + 1} candidate order: ${attemptCandidates + .map((candidate) => `${candidate.model.displayName}/${candidate.provider.type}`) + .join(" -> ")}` + ); + const failureReasons: string[] = []; for (let attemptIndex = 0; attemptIndex < attemptCandidates.length; attemptIndex += 1) { @@ -858,6 +878,9 @@ async function processSceneWithDiversity( failureReasons.push(`${candidate.model.displayName}: ${errorMessage}`); markSceneFusionCandidateFailed(candidate); console.error("[SceneFusion] Candidate failed, try next:", { + requestId: params.requestId, + sceneIndex: params.sceneIndex, + attempt: attemptIndex + 1, model: candidate.model.modelId, provider: candidate.provider.type, error: errorMessage, @@ -906,6 +929,7 @@ export async function POST(request: Request) { } const { productImage, productName, aspectRatio, imageSize, scenes } = parseResult.data; + const requestId = `sf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // 计算输出尺寸 const size = calculateSize(aspectRatio || "auto", imageSize || "2K"); @@ -936,7 +960,7 @@ export async function POST(request: Request) { if (candidateModels.length === 0) { return NextResponse.json( - { error: "未找到可用模型,请在后台将模型同时标记为“支持生图”和“支持图片输入(vision)”并启用" }, + { error: "未找到可用模型,请在后台启用至少一个支持生图的模型并配置供应商 API Key" }, { status: 500 } ); } @@ -956,6 +980,22 @@ export async function POST(request: Request) { .join(", ")}` ); } + const nonVisionCandidates = candidateModels.filter( + (candidate) => !candidate.model.supportsVision + ); + if (nonVisionCandidates.length > 0) { + console.warn( + `[SceneFusion] Models without supportsVision are included for failover diversity: ${nonVisionCandidates + .map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`) + .join(", ")}` + ); + } + const requestOffset = getNextSceneFusionRoundRobinOffset( + Math.max(1, candidateModels.length) + ); + console.log( + `[SceneFusion][${requestId}] Round-robin offset=${requestOffset}, scenes=${scenes.length}, candidates=${candidateModels.length}` + ); // 5. 预处理:将 Base64 图片上传到 S3 let productImageUrl = productImage; @@ -980,13 +1020,33 @@ export async function POST(request: Request) { const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); + let streamClosed = false; + const safeClose = () => { + if (streamClosed) return; + streamClosed = true; + try { + controller.close(); + } catch (closeError) { + console.warn("[SceneFusion] Stream close skipped:", closeError); + } + }; const enqueue = (payload: Record) => { - controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`)); + if (streamClosed) return false; + try { + controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`)); + return true; + } catch (enqueueError) { + streamClosed = true; + console.warn("[SceneFusion] Stream enqueue skipped:", enqueueError); + return false; + } }; try { // 发送初始消息 - enqueue({ type: "start", total: processedScenes.length }); + if (!enqueue({ type: "start", total: processedScenes.length })) { + return; + } let successCount = 0; let failCount = 0; @@ -999,6 +1059,7 @@ export async function POST(request: Request) { const workers = Array.from({ length: workerCount }, async () => { while (true) { + if (streamClosed) break; const currentIndex = nextSceneIndex; nextSceneIndex += 1; if (currentIndex >= processedScenes.length) break; @@ -1033,6 +1094,8 @@ export async function POST(request: Request) { aspectRatio, imageSize, sceneIndex: currentIndex, + requestOffset, + requestId, candidates: candidateModels, }); @@ -1057,7 +1120,7 @@ export async function POST(request: Request) { } successCount += 1; - enqueue({ + if (!enqueue({ type: "result", data: { id: scene.id, @@ -1069,18 +1132,22 @@ export async function POST(request: Request) { providerType: candidate.provider.type, attempts, }, - }); + })) { + break; + } } catch (error) { console.error(`[SceneFusion] Failed to process scene ${scene.id}:`, error); failCount += 1; - enqueue({ + if (!enqueue({ type: "result", data: { id: scene.id, status: "failed" as const, error: error instanceof Error ? error.message : "处理失败", }, - }); + })) { + break; + } } } }); @@ -1101,12 +1168,14 @@ export async function POST(request: Request) { } catch (error) { console.error("[SceneFusion] Stream error:", error); // 发送错误消息 - enqueue({ - type: "error", - error: error instanceof Error ? error.message : "处理失败", - }); + if (!streamClosed) { + enqueue({ + type: "error", + error: error instanceof Error ? error.message : "处理失败", + }); + } } finally { - controller.close(); + safeClose(); } }, });