fix(scene-fusion): diversify model pool and harden stream failover
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-05 19:56:57 +08:00
parent 879aa932fc
commit 4a55cc1b0f

View File

@ -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<string, number>();
let sceneFusionRoundRobinCursor = 0;
interface SceneFusionModelCandidate {
model: {
id: string;
modelId: string;
displayName: string;
supportsVision: boolean;
};
provider: {
type: string;
@ -119,6 +121,13 @@ function shuffleCandidates<T>(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<ReturnType<typeof getEnabledAiModelsWithProvider>>,
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<string, unknown>) => {
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();
}
},
});