From e300153a0129280c5aa619f3d9e6c113ffd254d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 11 Mar 2026 09:49:56 +0800 Subject: [PATCH] Add realtime canvas failover config --- .../settings/admin-settings-client.tsx | 119 +++++++ app/api/admin/settings/route.ts | 66 ++-- app/api/realtime/config/route.ts | 68 ++-- app/api/realtime/generate/route.ts | 296 ++++++++++-------- lib/realtime/config.ts | 186 +++++++++++ 5 files changed, 524 insertions(+), 211 deletions(-) create mode 100644 lib/realtime/config.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 7db6f45f3..70b1775e7 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -846,6 +846,10 @@ export function AdminSettingsClient() { bflApiKey: "", bflBaseUrl: DEFAULT_BFL_BASE_URL, bflModel: DEFAULT_BFL_REALTIME_MODEL, + fallbackEnabled: false, + fallbackBflApiKey: "", + fallbackBflBaseUrl: DEFAULT_BFL_BASE_URL, + fallbackBflModel: DEFAULT_BFL_REALTIME_MODEL, renderMode: "balanced" as "strict-structure" | "balanced" | "stylize", throttleInterval: 100, maxBuffering: 2, @@ -1004,6 +1008,13 @@ export function AdminSettingsClient() { bflApiKey?: string; bflBaseUrl?: string; bflModel?: string; + fallbackEnabled?: boolean; + fallbackBflApiKey?: string; + fallbackBflBaseUrl?: string; + fallbackBflModel?: string; + fallbackRunwareApiKey?: string; + fallbackFalApiKey?: string; + fallbackRunwareModel?: string; runwareApiKey?: string; falApiKey?: string; runwareModel?: string; @@ -1024,6 +1035,15 @@ export function AdminSettingsClient() { bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "", bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL, bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL, + fallbackEnabled: config.fallbackEnabled === true, + fallbackBflApiKey: + config.fallbackBflApiKey || + config.fallbackRunwareApiKey || + config.fallbackFalApiKey || + "", + fallbackBflBaseUrl: config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL, + fallbackBflModel: + config.fallbackBflModel || config.fallbackRunwareModel || DEFAULT_BFL_REALTIME_MODEL, renderMode: config.renderMode === "strict-structure" || config.renderMode === "stylize" ? config.renderMode @@ -2060,12 +2080,23 @@ export function AdminSettingsClient() { const bflApiKey = realtimeCanvasConfig.bflApiKey.trim(); const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL; const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL; + const fallbackEnabled = realtimeCanvasConfig.fallbackEnabled; + const fallbackBflApiKey = realtimeCanvasConfig.fallbackBflApiKey.trim(); + const fallbackBflBaseUrl = + realtimeCanvasConfig.fallbackBflBaseUrl.trim() || DEFAULT_BFL_BASE_URL; + const fallbackBflModel = + realtimeCanvasConfig.fallbackBflModel.trim() || DEFAULT_BFL_REALTIME_MODEL; if (realtimeCanvasConfig.enabled && !bflApiKey) { toast.error("启用实时画布功能需要配置 BFL API Key"); return; } + if (realtimeCanvasConfig.enabled && fallbackEnabled && !fallbackBflApiKey) { + toast.error("启用备用实时服务需要配置备用 BFL API Key"); + return; + } + if (realtimeCanvasConfig.enabled) { const probeResponse = await fetch("/api/admin/realtime/model-capability", { method: "POST", @@ -2081,6 +2112,23 @@ export function AdminSettingsClient() { toast.error(probeData?.message || "模型能力探针失败,已阻止保存"); return; } + + if (fallbackEnabled) { + const fallbackProbeResponse = await fetch("/api/admin/realtime/model-capability", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: fallbackBflApiKey, + modelId: fallbackBflModel, + baseUrl: fallbackBflBaseUrl, + }), + }); + const fallbackProbeData = await fallbackProbeResponse.json().catch(() => null); + if (!fallbackProbeResponse.ok || fallbackProbeData?.supported === false) { + toast.error(fallbackProbeData?.message || "备用实时服务探针失败,已阻止保存"); + return; + } + } } const response = await fetch("/api/admin/settings", { @@ -2093,6 +2141,10 @@ export function AdminSettingsClient() { bflApiKey, bflBaseUrl, bflModel, + fallbackEnabled, + fallbackBflApiKey, + fallbackBflBaseUrl, + fallbackBflModel, runwareApiKey: bflApiKey, falApiKey: bflApiKey, runwareModel: bflModel, @@ -8595,6 +8647,73 @@ export function AdminSettingsClient() { /> +
+
+
+ +
+ 主实时服务失败时自动切换到备用 BFL 服务。 +
+
+ + setRealtimeCanvasConfig({ ...realtimeCanvasConfig, fallbackEnabled: checked }) + } + disabled={!realtimeCanvasConfig.enabled} + /> +
+ +
+ + + setRealtimeCanvasConfig({ + ...realtimeCanvasConfig, + fallbackBflApiKey: e.target.value, + }) + } + placeholder="bfl_xxxxxxxxxxxxx" + disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled} + /> +
+ +
+ + + setRealtimeCanvasConfig({ + ...realtimeCanvasConfig, + fallbackBflBaseUrl: e.target.value, + }) + } + placeholder={DEFAULT_BFL_BASE_URL} + disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled} + /> +
+ +
+ + + setRealtimeCanvasConfig({ + ...realtimeCanvasConfig, + fallbackBflModel: e.target.value, + }) + } + placeholder={DEFAULT_BFL_REALTIME_MODEL} + disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled} + /> +
+
+
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 1f8ac1ef1..faaae2080 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -7,15 +7,16 @@ import { } from "@/lib/db/queries"; import { ChatSDKError } from "@/lib/errors"; import { probeBflModelSupport } from "@/lib/realtime/bfl-capability"; +import { + normalizeRealtimeCanvasConfigForStorage, + resolveRealtimeProviders, +} from "@/lib/realtime/config"; const settingSchema = z.object({ key: z.string(), value: z.any(), }); -const DEFAULT_BFL_MODEL = "flux-2-klein-4b"; -const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; - function isObjectLike(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -62,49 +63,34 @@ export async function POST(request: Request) { } const enabled = value.enabled !== false; - const bflApiKey = String( - value.bflApiKey || value.runwareApiKey || value.falApiKey || value.apiKey || "" - ).trim(); - const bflModel = String(value.bflModel || value.runwareModel || DEFAULT_BFL_MODEL).trim() || DEFAULT_BFL_MODEL; - const bflBaseUrl = String(value.bflBaseUrl || DEFAULT_BFL_BASE_URL).trim() || DEFAULT_BFL_BASE_URL; - const renderMode = - value.renderMode === "strict-structure" || - value.renderMode === "stylize" || - value.renderMode === "balanced" - ? value.renderMode - : "balanced"; + const normalizedValue = normalizeRealtimeCanvasConfigForStorage(value); + const providers = resolveRealtimeProviders(normalizedValue); if (enabled) { - if (!bflApiKey) { - return new ChatSDKError("bad_request:api", "启用实时画布必须配置 BFL API Key").toResponse(); + if (providers.length === 0) { + return new ChatSDKError("bad_request:api", "启用实时画布必须至少配置一个可用服务").toResponse(); } - const probeResult = await probeBflModelSupport({ - apiKey: bflApiKey, - modelId: bflModel, - baseUrl: bflBaseUrl, - }); - if (!probeResult.supported) { - return Response.json( - { - code: probeResult.code, - message: probeResult.message, - }, - { status: mapProbeStatus(probeResult.code) } - ); + + for (const provider of providers) { + const probeResult = await probeBflModelSupport({ + apiKey: provider.apiKey, + modelId: provider.model, + baseUrl: provider.baseUrl, + }); + + if (!probeResult.supported) { + return Response.json( + { + code: probeResult.code, + message: `${provider.label}校验失败:${probeResult.message}`, + }, + { status: mapProbeStatus(probeResult.code) } + ); + } } } - nextValue = { - ...value, - bflApiKey, - bflModel, - bflBaseUrl, - runwareApiKey: bflApiKey, - falApiKey: bflApiKey, - apiKey: bflApiKey, - runwareModel: bflModel, - renderMode, - }; + nextValue = normalizedValue; } const setting = await upsertSystemSetting({ diff --git a/app/api/realtime/config/route.ts b/app/api/realtime/config/route.ts index b9264fe87..99060e0b9 100644 --- a/app/api/realtime/config/route.ts +++ b/app/api/realtime/config/route.ts @@ -1,30 +1,10 @@ import { auth } from "@/app/(auth)/auth"; import { getSystemSetting } from "@/lib/db/queries"; - -type RealtimeCanvasConfig = { - enabled?: boolean; - bflApiKey?: string; - bflModel?: string; - bflBaseUrl?: string; - runwareApiKey?: string; - runwareModel?: string; - renderMode?: "strict-structure" | "balanced" | "stylize"; - falApiKey?: string; - apiKey?: string; - strictSketch?: boolean; - throttleInterval?: number; - maxBuffering?: number; - maxInputResolution?: number; - jpegQuality?: number; - defaults?: { - steps?: number; - cfgScale?: number; - strength?: number; - scheduleMu?: number; - feedback?: number; - imageSize?: "square" | "square_hd"; - }; -}; +import { + DEFAULT_BFL_BASE_URL, + RealtimeCanvasConfig, + resolveRealtimeProviders, +} from "@/lib/realtime/config"; const defaultConfig = { session: { @@ -78,21 +58,14 @@ export async function GET() { ); } - const bflApiKey = String( - config.bflApiKey || - config.runwareApiKey || - config.falApiKey || - config.apiKey || - process.env.BFL_API_KEY || - process.env.BFL_KEY || - "" - ).trim(); + const providers = resolveRealtimeProviders(config); + const activeProvider = providers[0]; - if (!bflApiKey) { + if (!activeProvider) { return Response.json( { code: "BFL_KEY_MISSING", - message: "未配置 BFL API Key(请在服务器环境变量或设置中配置)", + message: "未配置可用的实时画布服务(请在服务器环境变量或设置中配置)", }, { status: 500 } ); @@ -139,21 +112,24 @@ export async function GET() { defaultConfig.model.limits.strength.max ); const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square"; - const modelId = String( - config.bflModel || - config.runwareModel || - process.env.BFL_MODEL || - process.env.RUNWARE_MODEL || - defaultConfig.model.id - ).trim(); + const modelId = activeProvider.model; return Response.json({ ...defaultConfig, provider: { type: "bfl", - baseUrl: - String(config.bflBaseUrl || process.env.BFL_BASE_URL || "https://api.bfl.ai").trim() || - "https://api.bfl.ai", + baseUrl: activeProvider.baseUrl || DEFAULT_BFL_BASE_URL, + }, + failover: { + enabled: providers.length > 1, + fallbackProvider: + providers.length > 1 + ? { + type: "bfl", + baseUrl: providers[1].baseUrl, + model: providers[1].model, + } + : null, }, transport: { throttleInterval, diff --git a/app/api/realtime/generate/route.ts b/app/api/realtime/generate/route.ts index 808f0eb65..cd77e243c 100644 --- a/app/api/realtime/generate/route.ts +++ b/app/api/realtime/generate/route.ts @@ -1,18 +1,11 @@ import { auth } from "@/app/(auth)/auth"; import { getSystemSetting } from "@/lib/db/queries"; - -type RealtimeCanvasConfig = { - enabled?: boolean; - bflApiKey?: string; - bflModel?: string; - bflBaseUrl?: string; - runwareApiKey?: string; - runwareModel?: string; - falApiKey?: string; - apiKey?: string; - negativePrompt?: string; - maxInputResolution?: number; -}; +import { + DEFAULT_BFL_MODEL, + RealtimeCanvasConfig, + RealtimeProviderConfig, + resolveRealtimeProviders, +} from "@/lib/realtime/config"; type GeneratePayload = { requestId?: string; @@ -48,8 +41,6 @@ type BflSubmitResponse = { const FALLBACK_REALTIME_PROMPT = "严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。"; -const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; -const DEFAULT_BFL_MODEL = "flux-2-klein-4b"; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -72,11 +63,6 @@ function resolveSeed(seed: unknown) { return normalized; } -function normalizeBaseUrl(raw: string | undefined) { - const next = String(raw || DEFAULT_BFL_BASE_URL).trim(); - return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, ""); -} - function encodeModelPath(modelId: string) { return modelId .split("/") @@ -168,6 +154,22 @@ type SubmitTaskResult = { status: number; }; +type ProviderAttemptResult = + | { + ok: true; + imageUrl: string; + taskId: string; + imageField: "input_image" | "image_prompt"; + fallbackWarning: string; + provider: RealtimeProviderConfig; + } + | { + ok: false; + message: string; + status: number; + provider: RealtimeProviderConfig; + }; + async function submitBflTask(params: { apiKey: string; baseUrl: string; @@ -290,6 +292,97 @@ async function pollBflResult(params: { }; } +async function generateWithProvider(params: { + provider: RealtimeProviderConfig; + imagePrompt: string; + prompt: string; + size: number; + seed?: number; +}): Promise { + const imageFieldCandidates = getBflImageFieldCandidates(); + + let selectedImageField: "input_image" | "image_prompt" = imageFieldCandidates[0]; + let submitResult: SubmitTaskResult | null = null; + let fallbackWarning = ""; + + for (let index = 0; index < imageFieldCandidates.length; index += 1) { + selectedImageField = imageFieldCandidates[index]; + const requestBody: Record = { + prompt: params.prompt, + width: params.size, + height: params.size, + output_format: "jpeg", + safety_tolerance: 2, + [selectedImageField]: params.imagePrompt, + ...(typeof params.seed === "number" ? { seed: params.seed } : {}), + }; + + submitResult = await submitBflTask({ + apiKey: params.provider.apiKey, + baseUrl: params.provider.baseUrl, + model: params.provider.model, + body: requestBody, + }); + + if (!submitResult.errorMessage) { + break; + } + + if ( + index < imageFieldCandidates.length - 1 && + shouldRetryWithAlternateImageField(submitResult.errorMessage) + ) { + fallbackWarning = "模型参数兼容性已自动切换,继续生成。"; + continue; + } + + return { + ok: false, + message: submitResult.errorMessage || "实时生成提交失败", + status: submitResult.status || 502, + provider: params.provider, + }; + } + + if (!submitResult) { + return { + ok: false, + message: "实时生成提交失败", + status: 502, + provider: params.provider, + }; + } + + let finalImageUrl = submitResult.imageUrl; + if (!finalImageUrl) { + const pollResult = await pollBflResult({ + apiKey: params.provider.apiKey, + baseUrl: params.provider.baseUrl, + pollingUrl: submitResult.pollingUrl, + taskId: submitResult.taskId, + timeoutMs: 110_000, + }); + if (!pollResult.imageUrl) { + return { + ok: false, + message: pollResult.errorMessage || "实时生成未返回图像", + status: pollResult.status || 502, + provider: params.provider, + }; + } + finalImageUrl = pollResult.imageUrl; + } + + return { + ok: true, + imageUrl: finalImageUrl, + taskId: submitResult.taskId, + imageField: selectedImageField, + fallbackWarning, + provider: params.provider, + }; +} + export async function POST(request: Request) { const session = await auth(); if (!session?.user?.id) { @@ -318,33 +411,17 @@ export async function POST(request: Request) { ); } - const bflApiKey = String( - config.bflApiKey || - config.runwareApiKey || - config.falApiKey || - config.apiKey || - process.env.BFL_API_KEY || - process.env.BFL_KEY || - "" - ).trim(); - if (!bflApiKey) { + const providers = resolveRealtimeProviders(config); + if (providers.length === 0) { return Response.json( { code: "BFL_KEY_MISSING", - message: "未配置 BFL API Key", + message: "未配置可用的实时画布服务", }, { status: 500 } ); } - const model = String( - config.bflModel || - config.runwareModel || - process.env.BFL_MODEL || - process.env.RUNWARE_MODEL || - DEFAULT_BFL_MODEL - ).trim() || DEFAULT_BFL_MODEL; - const baseUrl = normalizeBaseUrl(config.bflBaseUrl || process.env.BFL_BASE_URL); const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536); const size = resolveSquareSize(body.imageSize, maxInputResolution); const seed = resolveSeed(body.seed); @@ -373,100 +450,69 @@ export async function POST(request: Request) { ); } - const imageFieldCandidates = getBflImageFieldCandidates(); + const providerErrors: string[] = []; - let selectedImageField: "input_image" | "image_prompt" = imageFieldCandidates[0]; - let submitResult: SubmitTaskResult | null = null; - let fallbackWarning = ""; - - for (let index = 0; index < imageFieldCandidates.length; index += 1) { - selectedImageField = imageFieldCandidates[index]; - const requestBody: Record = { + for (const provider of providers) { + const result = await generateWithProvider({ + provider, + imagePrompt, prompt: positivePrompt, - width: size, - height: size, - output_format: "jpeg", - safety_tolerance: 2, - [selectedImageField]: imagePrompt, - ...(typeof seed === "number" ? { seed } : {}), - }; - - submitResult = await submitBflTask({ - apiKey: bflApiKey, - baseUrl, - model, - body: requestBody, + size, + seed, }); - if (!submitResult.errorMessage) { - break; - } - - if (index < imageFieldCandidates.length - 1 && shouldRetryWithAlternateImageField(submitResult.errorMessage)) { - fallbackWarning = "模型参数兼容性已自动切换,继续生成。"; + if (!result.ok) { + providerErrors.push(`${provider.label}(${provider.baseUrl}): ${result.message}`); continue; } - break; - } - - if (!submitResult || submitResult.errorMessage) { - return Response.json( - { - code: "BFL_REQUEST_FAILED", - message: submitResult?.errorMessage || "BFL 请求失败", - }, - { status: 502 } - ); - } - - let finalImageUrl = submitResult.imageUrl; - if (!finalImageUrl) { - const pollResult = await pollBflResult({ - apiKey: bflApiKey, - baseUrl, - pollingUrl: submitResult.pollingUrl, - taskId: submitResult.taskId, - timeoutMs: 110_000, - }); - if (!pollResult.imageUrl) { - return Response.json( - { - code: "BFL_EMPTY_OUTPUT", - message: pollResult.errorMessage || "BFL 未返回图像", - }, - { status: 502 } - ); + const warningParts = [result.fallbackWarning]; + if (result.provider.source === "fallback") { + warningParts.push("主实时服务异常,已自动切换到备用服务。"); } - finalImageUrl = pollResult.imageUrl; + const warning = warningParts.filter(Boolean).join(" "); + + console.log("[RealtimeBFL] frame success", { + userId: session.user.id, + requestId: body.requestId || null, + model: result.provider.model || DEFAULT_BFL_MODEL, + providerSource: result.provider.source, + providerBaseUrl: result.provider.baseUrl, + taskId: result.taskId || null, + hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT), + imageField: result.imageField, + qualityProfile: body.qualityProfile || "refine", + size, + roi: body.roi || null, + hasMask: Boolean(body.maskImageUrl), + warning: warning || null, + }); + + return Response.json({ + ok: true, + provider: "bfl", + providerSource: result.provider.source, + model: result.provider.model, + transport: "http-poll", + taskUUID: result.taskId || undefined, + imageDataURI: result.imageUrl.startsWith("data:") + ? result.imageUrl + : !/^https?:\/\//i.test(result.imageUrl) + ? `data:image/jpeg;base64,${result.imageUrl}` + : undefined, + imageUrl: /^https?:\/\//i.test(result.imageUrl) ? result.imageUrl : undefined, + warning: warning || undefined, + }); } - console.log("[RealtimeBFL] frame success", { - userId: session.user.id, - requestId: body.requestId || null, - model, - taskId: submitResult.taskId || null, - hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT), - imageField: selectedImageField, - qualityProfile: body.qualityProfile || "refine", - size, - roi: body.roi || null, - hasMask: Boolean(body.maskImageUrl), - warning: fallbackWarning || null, - }); - - return Response.json({ - ok: true, - provider: "bfl", - model, - transport: "http-poll", - taskUUID: submitResult.taskId || undefined, - imageDataURI: finalImageUrl.startsWith("data:") - ? finalImageUrl - : !/^https?:\/\//i.test(finalImageUrl) - ? `data:image/jpeg;base64,${finalImageUrl}` - : undefined, - imageUrl: /^https?:\/\//i.test(finalImageUrl) ? finalImageUrl : undefined, - warning: fallbackWarning || undefined, - }); + return Response.json( + { + code: "BFL_REQUEST_FAILED", + message: + providerErrors.length > 0 + ? `主备用实时服务均不可用:${providerErrors.join(";")}` + : "实时生成失败", + }, + { status: 502 } + ); } diff --git a/lib/realtime/config.ts b/lib/realtime/config.ts new file mode 100644 index 000000000..20d600592 --- /dev/null +++ b/lib/realtime/config.ts @@ -0,0 +1,186 @@ +export const DEFAULT_BFL_MODEL = "flux-2-klein-4b"; +export const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; + +export type RealtimeCanvasConfig = { + enabled?: boolean; + bflApiKey?: string; + bflModel?: string; + bflBaseUrl?: string; + runwareApiKey?: string; + runwareModel?: string; + falApiKey?: string; + apiKey?: string; + fallbackEnabled?: boolean; + fallbackBflApiKey?: string; + fallbackBflModel?: string; + fallbackBflBaseUrl?: string; + fallbackRunwareApiKey?: string; + fallbackRunwareModel?: string; + fallbackFalApiKey?: string; + fallbackApiKey?: string; + negativePrompt?: string; + renderMode?: "strict-structure" | "balanced" | "stylize"; + strictSketch?: boolean; + throttleInterval?: number; + maxBuffering?: number; + maxInputResolution?: number; + jpegQuality?: number; + defaults?: { + steps?: number; + cfgScale?: number; + strength?: number; + scheduleMu?: number; + feedback?: number; + imageSize?: "square" | "square_hd"; + }; +}; + +export type RealtimeProviderConfig = { + apiKey: string; + baseUrl: string; + model: string; + source: "primary" | "fallback"; + label: string; +}; + +function trimString(value: unknown) { + return String(value || "").trim(); +} + +export function normalizeBaseUrl(raw: string | undefined) { + const next = trimString(raw || DEFAULT_BFL_BASE_URL); + return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, ""); +} + +function hasFallbackIntent(config: RealtimeCanvasConfig) { + if (config.fallbackEnabled === true) return true; + if (config.fallbackEnabled === false) return false; + + return Boolean( + trimString( + config.fallbackBflApiKey || + config.fallbackRunwareApiKey || + config.fallbackFalApiKey || + config.fallbackApiKey || + config.fallbackBflBaseUrl || + config.fallbackBflModel || + config.fallbackRunwareModel + ) + ); +} + +export function resolvePrimaryRealtimeProvider( + config: RealtimeCanvasConfig, + env: NodeJS.ProcessEnv = process.env +): RealtimeProviderConfig | null { + const apiKey = trimString( + config.bflApiKey || + config.runwareApiKey || + config.falApiKey || + config.apiKey || + env.BFL_API_KEY || + env.BFL_KEY + ); + if (!apiKey) return null; + + const model = + trimString(config.bflModel || config.runwareModel || env.BFL_MODEL || env.RUNWARE_MODEL) || + DEFAULT_BFL_MODEL; + const baseUrl = normalizeBaseUrl(config.bflBaseUrl || env.BFL_BASE_URL); + + return { + apiKey, + model, + baseUrl, + source: "primary", + label: "主实时服务", + }; +} + +export function resolveFallbackRealtimeProvider( + config: RealtimeCanvasConfig +): RealtimeProviderConfig | null { + if (!hasFallbackIntent(config)) return null; + + const apiKey = trimString( + config.fallbackBflApiKey || + config.fallbackRunwareApiKey || + config.fallbackFalApiKey || + config.fallbackApiKey + ); + if (!apiKey) return null; + + const model = + trimString(config.fallbackBflModel || config.fallbackRunwareModel) || DEFAULT_BFL_MODEL; + const baseUrl = normalizeBaseUrl(config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL); + + return { + apiKey, + model, + baseUrl, + source: "fallback", + label: "备用实时服务", + }; +} + +export function resolveRealtimeProviders( + config: RealtimeCanvasConfig, + env: NodeJS.ProcessEnv = process.env +) { + const providers: RealtimeProviderConfig[] = []; + const primary = resolvePrimaryRealtimeProvider(config, env); + const fallback = resolveFallbackRealtimeProvider(config); + + if (primary) { + providers.push(primary); + } + + if ( + fallback && + !providers.some( + (provider) => + provider.apiKey === fallback.apiKey && + provider.baseUrl === fallback.baseUrl && + provider.model === fallback.model + ) + ) { + providers.push(fallback); + } + + return providers; +} + +export function normalizeRealtimeCanvasConfigForStorage( + value: Record, + env: NodeJS.ProcessEnv = process.env +): RealtimeCanvasConfig { + const renderMode = + value.renderMode === "strict-structure" || + value.renderMode === "stylize" || + value.renderMode === "balanced" + ? value.renderMode + : "balanced"; + + const primary = resolvePrimaryRealtimeProvider(value as RealtimeCanvasConfig, env); + const fallback = resolveFallbackRealtimeProvider(value as RealtimeCanvasConfig); + + return { + ...value, + bflApiKey: primary?.apiKey || "", + bflModel: primary?.model || DEFAULT_BFL_MODEL, + bflBaseUrl: primary?.baseUrl || normalizeBaseUrl(env.BFL_BASE_URL), + runwareApiKey: primary?.apiKey || "", + falApiKey: primary?.apiKey || "", + apiKey: primary?.apiKey || "", + runwareModel: primary?.model || DEFAULT_BFL_MODEL, + fallbackEnabled: Boolean(fallback), + fallbackBflApiKey: fallback?.apiKey || "", + fallbackBflModel: fallback?.model || DEFAULT_BFL_MODEL, + fallbackBflBaseUrl: fallback?.baseUrl || DEFAULT_BFL_BASE_URL, + fallbackRunwareApiKey: fallback?.apiKey || "", + fallbackFalApiKey: fallback?.apiKey || "", + fallbackApiKey: fallback?.apiKey || "", + fallbackRunwareModel: fallback?.model || DEFAULT_BFL_MODEL, + renderMode, + }; +}