Add realtime canvas failover config
This commit is contained in:
parent
9c6abd93da
commit
e300153a01
@ -846,6 +846,10 @@ export function AdminSettingsClient() {
|
|||||||
bflApiKey: "",
|
bflApiKey: "",
|
||||||
bflBaseUrl: DEFAULT_BFL_BASE_URL,
|
bflBaseUrl: DEFAULT_BFL_BASE_URL,
|
||||||
bflModel: DEFAULT_BFL_REALTIME_MODEL,
|
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",
|
renderMode: "balanced" as "strict-structure" | "balanced" | "stylize",
|
||||||
throttleInterval: 100,
|
throttleInterval: 100,
|
||||||
maxBuffering: 2,
|
maxBuffering: 2,
|
||||||
@ -1004,6 +1008,13 @@ export function AdminSettingsClient() {
|
|||||||
bflApiKey?: string;
|
bflApiKey?: string;
|
||||||
bflBaseUrl?: string;
|
bflBaseUrl?: string;
|
||||||
bflModel?: string;
|
bflModel?: string;
|
||||||
|
fallbackEnabled?: boolean;
|
||||||
|
fallbackBflApiKey?: string;
|
||||||
|
fallbackBflBaseUrl?: string;
|
||||||
|
fallbackBflModel?: string;
|
||||||
|
fallbackRunwareApiKey?: string;
|
||||||
|
fallbackFalApiKey?: string;
|
||||||
|
fallbackRunwareModel?: string;
|
||||||
runwareApiKey?: string;
|
runwareApiKey?: string;
|
||||||
falApiKey?: string;
|
falApiKey?: string;
|
||||||
runwareModel?: string;
|
runwareModel?: string;
|
||||||
@ -1024,6 +1035,15 @@ export function AdminSettingsClient() {
|
|||||||
bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
|
bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
|
||||||
bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
|
bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
|
||||||
bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL,
|
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:
|
renderMode:
|
||||||
config.renderMode === "strict-structure" || config.renderMode === "stylize"
|
config.renderMode === "strict-structure" || config.renderMode === "stylize"
|
||||||
? config.renderMode
|
? config.renderMode
|
||||||
@ -2060,12 +2080,23 @@ export function AdminSettingsClient() {
|
|||||||
const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
|
const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
|
||||||
const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
|
const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
|
||||||
const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
|
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) {
|
if (realtimeCanvasConfig.enabled && !bflApiKey) {
|
||||||
toast.error("启用实时画布功能需要配置 BFL API Key");
|
toast.error("启用实时画布功能需要配置 BFL API Key");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (realtimeCanvasConfig.enabled && fallbackEnabled && !fallbackBflApiKey) {
|
||||||
|
toast.error("启用备用实时服务需要配置备用 BFL API Key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (realtimeCanvasConfig.enabled) {
|
if (realtimeCanvasConfig.enabled) {
|
||||||
const probeResponse = await fetch("/api/admin/realtime/model-capability", {
|
const probeResponse = await fetch("/api/admin/realtime/model-capability", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -2081,6 +2112,23 @@ export function AdminSettingsClient() {
|
|||||||
toast.error(probeData?.message || "模型能力探针失败,已阻止保存");
|
toast.error(probeData?.message || "模型能力探针失败,已阻止保存");
|
||||||
return;
|
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", {
|
const response = await fetch("/api/admin/settings", {
|
||||||
@ -2093,6 +2141,10 @@ export function AdminSettingsClient() {
|
|||||||
bflApiKey,
|
bflApiKey,
|
||||||
bflBaseUrl,
|
bflBaseUrl,
|
||||||
bflModel,
|
bflModel,
|
||||||
|
fallbackEnabled,
|
||||||
|
fallbackBflApiKey,
|
||||||
|
fallbackBflBaseUrl,
|
||||||
|
fallbackBflModel,
|
||||||
runwareApiKey: bflApiKey,
|
runwareApiKey: bflApiKey,
|
||||||
falApiKey: bflApiKey,
|
falApiKey: bflApiKey,
|
||||||
runwareModel: bflModel,
|
runwareModel: bflModel,
|
||||||
@ -8595,6 +8647,73 @@ export function AdminSettingsClient() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-base">备用实时服务</Label>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
主实时服务失败时自动切换到备用 BFL 服务。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={realtimeCanvasConfig.fallbackEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, fallbackEnabled: checked })
|
||||||
|
}
|
||||||
|
disabled={!realtimeCanvasConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="realtimeFallbackBflApiKey">备用 BFL API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="realtimeFallbackBflApiKey"
|
||||||
|
type="password"
|
||||||
|
value={realtimeCanvasConfig.fallbackBflApiKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRealtimeCanvasConfig({
|
||||||
|
...realtimeCanvasConfig,
|
||||||
|
fallbackBflApiKey: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="bfl_xxxxxxxxxxxxx"
|
||||||
|
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="realtimeFallbackBflBaseUrl">备用 BFL API Base URL</Label>
|
||||||
|
<Input
|
||||||
|
id="realtimeFallbackBflBaseUrl"
|
||||||
|
value={realtimeCanvasConfig.fallbackBflBaseUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRealtimeCanvasConfig({
|
||||||
|
...realtimeCanvasConfig,
|
||||||
|
fallbackBflBaseUrl: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={DEFAULT_BFL_BASE_URL}
|
||||||
|
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="realtimeFallbackBflModel">备用 FLUX.2 模型 ID</Label>
|
||||||
|
<Input
|
||||||
|
id="realtimeFallbackBflModel"
|
||||||
|
value={realtimeCanvasConfig.fallbackBflModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRealtimeCanvasConfig({
|
||||||
|
...realtimeCanvasConfig,
|
||||||
|
fallbackBflModel: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={DEFAULT_BFL_REALTIME_MODEL}
|
||||||
|
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>实时渲染模式</Label>
|
<Label>实时渲染模式</Label>
|
||||||
|
|||||||
@ -7,15 +7,16 @@ import {
|
|||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
|
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
|
||||||
|
import {
|
||||||
|
normalizeRealtimeCanvasConfigForStorage,
|
||||||
|
resolveRealtimeProviders,
|
||||||
|
} from "@/lib/realtime/config";
|
||||||
|
|
||||||
const settingSchema = z.object({
|
const settingSchema = z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
value: z.any(),
|
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<string, unknown> {
|
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
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 enabled = value.enabled !== false;
|
||||||
const bflApiKey = String(
|
const normalizedValue = normalizeRealtimeCanvasConfigForStorage(value);
|
||||||
value.bflApiKey || value.runwareApiKey || value.falApiKey || value.apiKey || ""
|
const providers = resolveRealtimeProviders(normalizedValue);
|
||||||
).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";
|
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (!bflApiKey) {
|
if (providers.length === 0) {
|
||||||
return new ChatSDKError("bad_request:api", "启用实时画布必须配置 BFL API Key").toResponse();
|
return new ChatSDKError("bad_request:api", "启用实时画布必须至少配置一个可用服务").toResponse();
|
||||||
}
|
}
|
||||||
const probeResult = await probeBflModelSupport({
|
|
||||||
apiKey: bflApiKey,
|
for (const provider of providers) {
|
||||||
modelId: bflModel,
|
const probeResult = await probeBflModelSupport({
|
||||||
baseUrl: bflBaseUrl,
|
apiKey: provider.apiKey,
|
||||||
});
|
modelId: provider.model,
|
||||||
if (!probeResult.supported) {
|
baseUrl: provider.baseUrl,
|
||||||
return Response.json(
|
});
|
||||||
{
|
|
||||||
code: probeResult.code,
|
if (!probeResult.supported) {
|
||||||
message: probeResult.message,
|
return Response.json(
|
||||||
},
|
{
|
||||||
{ status: mapProbeStatus(probeResult.code) }
|
code: probeResult.code,
|
||||||
);
|
message: `${provider.label}校验失败:${probeResult.message}`,
|
||||||
|
},
|
||||||
|
{ status: mapProbeStatus(probeResult.code) }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextValue = {
|
nextValue = normalizedValue;
|
||||||
...value,
|
|
||||||
bflApiKey,
|
|
||||||
bflModel,
|
|
||||||
bflBaseUrl,
|
|
||||||
runwareApiKey: bflApiKey,
|
|
||||||
falApiKey: bflApiKey,
|
|
||||||
apiKey: bflApiKey,
|
|
||||||
runwareModel: bflModel,
|
|
||||||
renderMode,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setting = await upsertSystemSetting({
|
const setting = await upsertSystemSetting({
|
||||||
|
|||||||
@ -1,30 +1,10 @@
|
|||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getSystemSetting } from "@/lib/db/queries";
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
type RealtimeCanvasConfig = {
|
DEFAULT_BFL_BASE_URL,
|
||||||
enabled?: boolean;
|
RealtimeCanvasConfig,
|
||||||
bflApiKey?: string;
|
resolveRealtimeProviders,
|
||||||
bflModel?: string;
|
} from "@/lib/realtime/config";
|
||||||
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";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
session: {
|
session: {
|
||||||
@ -78,21 +58,14 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bflApiKey = String(
|
const providers = resolveRealtimeProviders(config);
|
||||||
config.bflApiKey ||
|
const activeProvider = providers[0];
|
||||||
config.runwareApiKey ||
|
|
||||||
config.falApiKey ||
|
|
||||||
config.apiKey ||
|
|
||||||
process.env.BFL_API_KEY ||
|
|
||||||
process.env.BFL_KEY ||
|
|
||||||
""
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
if (!bflApiKey) {
|
if (!activeProvider) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
code: "BFL_KEY_MISSING",
|
code: "BFL_KEY_MISSING",
|
||||||
message: "未配置 BFL API Key(请在服务器环境变量或设置中配置)",
|
message: "未配置可用的实时画布服务(请在服务器环境变量或设置中配置)",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
@ -139,21 +112,24 @@ export async function GET() {
|
|||||||
defaultConfig.model.limits.strength.max
|
defaultConfig.model.limits.strength.max
|
||||||
);
|
);
|
||||||
const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
|
const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
|
||||||
const modelId = String(
|
const modelId = activeProvider.model;
|
||||||
config.bflModel ||
|
|
||||||
config.runwareModel ||
|
|
||||||
process.env.BFL_MODEL ||
|
|
||||||
process.env.RUNWARE_MODEL ||
|
|
||||||
defaultConfig.model.id
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
provider: {
|
provider: {
|
||||||
type: "bfl",
|
type: "bfl",
|
||||||
baseUrl:
|
baseUrl: activeProvider.baseUrl || DEFAULT_BFL_BASE_URL,
|
||||||
String(config.bflBaseUrl || process.env.BFL_BASE_URL || "https://api.bfl.ai").trim() ||
|
},
|
||||||
"https://api.bfl.ai",
|
failover: {
|
||||||
|
enabled: providers.length > 1,
|
||||||
|
fallbackProvider:
|
||||||
|
providers.length > 1
|
||||||
|
? {
|
||||||
|
type: "bfl",
|
||||||
|
baseUrl: providers[1].baseUrl,
|
||||||
|
model: providers[1].model,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
transport: {
|
transport: {
|
||||||
throttleInterval,
|
throttleInterval,
|
||||||
|
|||||||
@ -1,18 +1,11 @@
|
|||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getSystemSetting } from "@/lib/db/queries";
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
type RealtimeCanvasConfig = {
|
DEFAULT_BFL_MODEL,
|
||||||
enabled?: boolean;
|
RealtimeCanvasConfig,
|
||||||
bflApiKey?: string;
|
RealtimeProviderConfig,
|
||||||
bflModel?: string;
|
resolveRealtimeProviders,
|
||||||
bflBaseUrl?: string;
|
} from "@/lib/realtime/config";
|
||||||
runwareApiKey?: string;
|
|
||||||
runwareModel?: string;
|
|
||||||
falApiKey?: string;
|
|
||||||
apiKey?: string;
|
|
||||||
negativePrompt?: string;
|
|
||||||
maxInputResolution?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeneratePayload = {
|
type GeneratePayload = {
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@ -48,8 +41,6 @@ type BflSubmitResponse = {
|
|||||||
|
|
||||||
const FALLBACK_REALTIME_PROMPT =
|
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) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
@ -72,11 +63,6 @@ function resolveSeed(seed: unknown) {
|
|||||||
return normalized;
|
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) {
|
function encodeModelPath(modelId: string) {
|
||||||
return modelId
|
return modelId
|
||||||
.split("/")
|
.split("/")
|
||||||
@ -168,6 +154,22 @@ type SubmitTaskResult = {
|
|||||||
status: number;
|
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: {
|
async function submitBflTask(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: 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<ProviderAttemptResult> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@ -318,33 +411,17 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bflApiKey = String(
|
const providers = resolveRealtimeProviders(config);
|
||||||
config.bflApiKey ||
|
if (providers.length === 0) {
|
||||||
config.runwareApiKey ||
|
|
||||||
config.falApiKey ||
|
|
||||||
config.apiKey ||
|
|
||||||
process.env.BFL_API_KEY ||
|
|
||||||
process.env.BFL_KEY ||
|
|
||||||
""
|
|
||||||
).trim();
|
|
||||||
if (!bflApiKey) {
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
code: "BFL_KEY_MISSING",
|
code: "BFL_KEY_MISSING",
|
||||||
message: "未配置 BFL API Key",
|
message: "未配置可用的实时画布服务",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ 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 maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536);
|
||||||
const size = resolveSquareSize(body.imageSize, maxInputResolution);
|
const size = resolveSquareSize(body.imageSize, maxInputResolution);
|
||||||
const seed = resolveSeed(body.seed);
|
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];
|
for (const provider of providers) {
|
||||||
let submitResult: SubmitTaskResult | null = null;
|
const result = await generateWithProvider({
|
||||||
let fallbackWarning = "";
|
provider,
|
||||||
|
imagePrompt,
|
||||||
for (let index = 0; index < imageFieldCandidates.length; index += 1) {
|
|
||||||
selectedImageField = imageFieldCandidates[index];
|
|
||||||
const requestBody: Record<string, unknown> = {
|
|
||||||
prompt: positivePrompt,
|
prompt: positivePrompt,
|
||||||
width: size,
|
size,
|
||||||
height: size,
|
seed,
|
||||||
output_format: "jpeg",
|
|
||||||
safety_tolerance: 2,
|
|
||||||
[selectedImageField]: imagePrompt,
|
|
||||||
...(typeof seed === "number" ? { seed } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
submitResult = await submitBflTask({
|
|
||||||
apiKey: bflApiKey,
|
|
||||||
baseUrl,
|
|
||||||
model,
|
|
||||||
body: requestBody,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!submitResult.errorMessage) {
|
if (!result.ok) {
|
||||||
break;
|
providerErrors.push(`${provider.label}(${provider.baseUrl}): ${result.message}`);
|
||||||
}
|
|
||||||
|
|
||||||
if (index < imageFieldCandidates.length - 1 && shouldRetryWithAlternateImageField(submitResult.errorMessage)) {
|
|
||||||
fallbackWarning = "模型参数兼容性已自动切换,继续生成。";
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
const warningParts = [result.fallbackWarning];
|
||||||
}
|
if (result.provider.source === "fallback") {
|
||||||
|
warningParts.push("主实时服务异常,已自动切换到备用服务。");
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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", {
|
return Response.json(
|
||||||
userId: session.user.id,
|
{
|
||||||
requestId: body.requestId || null,
|
code: "BFL_REQUEST_FAILED",
|
||||||
model,
|
message:
|
||||||
taskId: submitResult.taskId || null,
|
providerErrors.length > 0
|
||||||
hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT),
|
? `主备用实时服务均不可用:${providerErrors.join(";")}`
|
||||||
imageField: selectedImageField,
|
: "实时生成失败",
|
||||||
qualityProfile: body.qualityProfile || "refine",
|
},
|
||||||
size,
|
{ status: 502 }
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
186
lib/realtime/config.ts
Normal file
186
lib/realtime/config.ts
Normal file
@ -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<string, unknown>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user