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,
+ };
+}