fix(scene-fusion): diversify model pool and harden stream failover
This commit is contained in:
parent
879aa932fc
commit
4a55cc1b0f
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user