feat(realtime-canvas): migrate provider to BFL flux.2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 10:18:56 +08:00
parent 795fcff90f
commit b5fdbd7c10
8 changed files with 617 additions and 922 deletions

View File

@ -102,8 +102,8 @@ import {
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1";
const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1";
const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b";
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
function createEmptyCanvasSkillTag(): CanvasSkillTag {
return {
@ -810,11 +810,9 @@ export function AdminSettingsClient() {
// 实时画布API配置状态
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
enabled: true,
falApiKey: "",
runwareModel: DEFAULT_RUNWARE_REALTIME_MODEL,
runwareStylizeModel: DEFAULT_RUNWARE_STYLIZE_MODEL,
runwareControlModel: "runware:25@1",
runwareControlPreprocessor: "canny" as "canny" | "lineart" | "depth",
bflApiKey: "",
bflBaseUrl: DEFAULT_BFL_BASE_URL,
bflModel: DEFAULT_BFL_REALTIME_MODEL,
renderMode: "balanced" as "strict-structure" | "balanced" | "stylize",
throttleInterval: 100,
maxBuffering: 2,
@ -970,12 +968,12 @@ export function AdminSettingsClient() {
if (realtimeCanvasConfigData?.setting?.value) {
const config = realtimeCanvasConfigData.setting.value as {
enabled?: boolean;
bflApiKey?: string;
bflBaseUrl?: string;
bflModel?: string;
runwareApiKey?: string;
falApiKey?: string;
runwareModel?: string;
runwareStylizeModel?: string;
runwareControlModel?: string;
runwareControlPreprocessor?: "canny" | "lineart" | "depth";
renderMode?: "strict-structure" | "balanced" | "stylize";
throttleInterval?: number;
maxBuffering?: number;
@ -990,15 +988,9 @@ export function AdminSettingsClient() {
};
setRealtimeCanvasConfig({
enabled: config.enabled ?? true,
falApiKey: config.runwareApiKey || config.falApiKey || "",
runwareModel: config.runwareModel || DEFAULT_RUNWARE_REALTIME_MODEL,
runwareStylizeModel: config.runwareStylizeModel || DEFAULT_RUNWARE_STYLIZE_MODEL,
runwareControlModel: config.runwareControlModel || "runware:25@1",
runwareControlPreprocessor:
config.runwareControlPreprocessor === "lineart" ||
config.runwareControlPreprocessor === "depth"
? config.runwareControlPreprocessor
: "canny",
bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL,
renderMode:
config.renderMode === "strict-structure" || config.renderMode === "stylize"
? config.renderMode
@ -2031,36 +2023,29 @@ export function AdminSettingsClient() {
// --- 实时画布API配置逻辑 ---
const handleSaveRealtimeCanvasConfig = async () => {
try {
const runwareApiKey = realtimeCanvasConfig.falApiKey.trim();
const runwareModel =
realtimeCanvasConfig.runwareModel.trim() || DEFAULT_RUNWARE_REALTIME_MODEL;
const runwareStylizeModel =
realtimeCanvasConfig.runwareStylizeModel.trim() || DEFAULT_RUNWARE_STYLIZE_MODEL;
const runwareControlModel =
realtimeCanvasConfig.runwareControlModel.trim() || "runware:25@1";
const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
if (realtimeCanvasConfig.enabled && !runwareApiKey) {
toast.error("启用实时画布功能需要配置 Runware API Key");
if (realtimeCanvasConfig.enabled && !bflApiKey) {
toast.error("启用实时画布功能需要配置 BFL API Key");
return;
}
if (realtimeCanvasConfig.enabled) {
for (const modelId of [runwareModel, runwareStylizeModel].filter(Boolean)) {
const probeResponse = await fetch("/api/admin/realtime/model-capability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: runwareApiKey,
modelId,
}),
});
const probeData = await probeResponse.json().catch(() => null);
if (!probeResponse.ok || probeData?.supported === false) {
toast.error(
probeData?.message || "模型能力探针失败:当前模型不支持 seedImage,已阻止保存"
);
return;
}
const probeResponse = await fetch("/api/admin/realtime/model-capability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: bflApiKey,
modelId: bflModel,
baseUrl: bflBaseUrl,
}),
});
const probeData = await probeResponse.json().catch(() => null);
if (!probeResponse.ok || probeData?.supported === false) {
toast.error(probeData?.message || "模型能力探针失败,已阻止保存");
return;
}
}
@ -2071,12 +2056,12 @@ export function AdminSettingsClient() {
key: "realtime_canvas_config",
value: {
enabled: realtimeCanvasConfig.enabled,
runwareApiKey,
falApiKey: runwareApiKey,
runwareModel,
runwareStylizeModel,
runwareControlModel,
runwareControlPreprocessor: realtimeCanvasConfig.runwareControlPreprocessor,
bflApiKey,
bflBaseUrl,
bflModel,
runwareApiKey: bflApiKey,
falApiKey: bflApiKey,
runwareModel: bflModel,
renderMode: realtimeCanvasConfig.renderMode,
throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)),
maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)),
@ -8359,7 +8344,7 @@ export function AdminSettingsClient() {
实时画布配置
</CardTitle>
<CardDescription>
配置实时画布功能的 Runware 参数、模型与 API Key。保存时会自动探针校验模型是否支持 seedImage。
配置实时画布功能的 BFL(FLUX.2)参数、模型与 API Key。保存时会自动探针校验模型可用性。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
@ -8384,67 +8369,50 @@ export function AdminSettingsClient() {
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFalApiKey">
Runware API Key <span className="text-destructive">*</span>
<Label htmlFor="realtimeBflApiKey">
BFL API Key <span className="text-destructive">*</span>
</Label>
<Input
id="realtimeFalApiKey"
id="realtimeBflApiKey"
type="password"
value={realtimeCanvasConfig.falApiKey}
value={realtimeCanvasConfig.bflApiKey}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, falApiKey: e.target.value })
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflApiKey: e.target.value })
}
placeholder="rw_xxxxxxxxxxxxx"
placeholder="bfl_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="runwareModel">
Runware 模型 ID <span className="text-destructive">*</span>
<Label htmlFor="realtimeBflBaseUrl">BFL API Base URL</Label>
<Input
id="realtimeBflBaseUrl"
value={realtimeCanvasConfig.bflBaseUrl}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflBaseUrl: e.target.value })
}
placeholder={DEFAULT_BFL_BASE_URL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeBflModel">
FLUX.2 模型 ID <span className="text-destructive">*</span>
</Label>
<Input
id="runwareModel"
value={realtimeCanvasConfig.runwareModel}
id="realtimeBflModel"
value={realtimeCanvasConfig.bflModel}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, runwareModel: e.target.value })
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflModel: e.target.value })
}
placeholder={DEFAULT_RUNWARE_REALTIME_MODEL}
placeholder={DEFAULT_BFL_REALTIME_MODEL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="runwareStylizeModel">写实增强模型 ID</Label>
<Input
id="runwareStylizeModel"
value={realtimeCanvasConfig.runwareStylizeModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
runwareStylizeModel: e.target.value,
})
}
placeholder={DEFAULT_RUNWARE_STYLIZE_MODEL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="runwareControlModel">ControlNet 模型 ID</Label>
<Input
id="runwareControlModel"
value={realtimeCanvasConfig.runwareControlModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
runwareControlModel: e.target.value,
})
}
placeholder="runware:25@1"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label>实时渲染模式</Label>
<Select
@ -8465,30 +8433,9 @@ export function AdminSettingsClient() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>ControlNet 预处理</Label>
<Select
value={realtimeCanvasConfig.runwareControlPreprocessor}
onValueChange={(value: "canny" | "lineart" | "depth") =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
runwareControlPreprocessor: value,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="canny">canny</SelectItem>
<SelectItem value="lineart">lineart</SelectItem>
<SelectItem value="depth">depth</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
推荐:主模型 {DEFAULT_RUNWARE_REALTIME_MODEL} + ControlNet {`runware:25@1`}。写实模型 {DEFAULT_RUNWARE_STYLIZE_MODEL} 作为回退链路。
推荐:实时草图模型使用 `flux-2-klein-4b`,高质量可切换 `flux-2-pro-preview`。
</p>
<div className="grid grid-cols-2 gap-4">

View File

@ -1,11 +1,12 @@
import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { probeRunwareSeedImageSupport } from "@/lib/realtime/runware-capability";
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
const schema = z.object({
apiKey: z.string().min(1),
modelId: z.string().min(1),
baseUrl: z.string().optional(),
});
function mapStatus(code: string) {
@ -19,9 +20,10 @@ export async function POST(request: Request) {
const body = await request.json();
const data = schema.parse(body);
const result = await probeRunwareSeedImageSupport({
const result = await probeBflModelSupport({
apiKey: data.apiKey,
modelId: data.modelId,
baseUrl: data.baseUrl,
});
if (!result.supported) {

View File

@ -6,16 +6,15 @@ import {
upsertSystemSetting,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { probeRunwareSeedImageSupport } from "@/lib/realtime/runware-capability";
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
const settingSchema = z.object({
key: z.string(),
value: z.any(),
});
const DEFAULT_RUNWARE_MODEL = "runware:101@1";
const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1";
const DEFAULT_RUNWARE_CONTROL_MODEL = "runware:25@1";
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> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@ -63,18 +62,11 @@ export async function POST(request: Request) {
}
const enabled = value.enabled !== false;
const runwareApiKey = String(value.runwareApiKey || value.falApiKey || value.apiKey || "").trim();
const runwareModel = String(value.runwareModel || DEFAULT_RUNWARE_MODEL).trim() || DEFAULT_RUNWARE_MODEL;
const runwareStylizeModel =
String(value.runwareStylizeModel || DEFAULT_RUNWARE_STYLIZE_MODEL).trim() ||
DEFAULT_RUNWARE_STYLIZE_MODEL;
const runwareControlModel =
String(value.runwareControlModel || DEFAULT_RUNWARE_CONTROL_MODEL).trim() ||
DEFAULT_RUNWARE_CONTROL_MODEL;
const runwareControlPreprocessor =
value.runwareControlPreprocessor === "lineart" || value.runwareControlPreprocessor === "depth"
? value.runwareControlPreprocessor
: "canny";
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" ||
@ -83,34 +75,34 @@ export async function POST(request: Request) {
: "balanced";
if (enabled) {
if (!runwareApiKey) {
return new ChatSDKError("bad_request:api", "启用实时画布必须配置 Runware API Key").toResponse();
if (!bflApiKey) {
return new ChatSDKError("bad_request:api", "启用实时画布必须配置 BFL API Key").toResponse();
}
for (const modelId of [runwareModel, runwareStylizeModel].filter(Boolean)) {
const probeResult = await probeRunwareSeedImageSupport({
apiKey: runwareApiKey,
modelId,
});
if (!probeResult.supported) {
return Response.json(
{
code: probeResult.code,
message: probeResult.message,
},
{ status: mapProbeStatus(probeResult.code) }
);
}
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) }
);
}
}
nextValue = {
...value,
runwareApiKey,
falApiKey: runwareApiKey,
runwareModel,
runwareStylizeModel,
runwareControlModel,
runwareControlPreprocessor,
bflApiKey,
bflModel,
bflBaseUrl,
runwareApiKey: bflApiKey,
falApiKey: bflApiKey,
apiKey: bflApiKey,
runwareModel: bflModel,
renderMode,
};
}

View File

@ -3,10 +3,11 @@ import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = {
enabled?: boolean;
bflApiKey?: string;
bflModel?: string;
bflBaseUrl?: string;
runwareApiKey?: string;
runwareModel?: string;
runwareControlModel?: string;
runwareControlPreprocessor?: "canny" | "lineart" | "depth";
renderMode?: "strict-structure" | "balanced" | "stylize";
falApiKey?: string;
apiKey?: string;
@ -38,15 +39,15 @@ const defaultConfig = {
jpegQuality: 0.55,
},
model: {
id: "runware:101@1",
id: "flux-2-klein-4b",
defaults: {
steps: 8,
steps: 3,
cfgScale: 7,
strength: 0.32,
imageSize: "square" as const,
},
limits: {
steps: { min: 1, max: 12 },
steps: { min: 1, max: 6 },
cfgScale: { min: 1, max: 14 },
strength: { min: 0.05, max: 1.0 },
},
@ -77,20 +78,21 @@ export async function GET() {
);
}
const runwareApiKey = String(
config.runwareApiKey ||
const bflApiKey = String(
config.bflApiKey ||
config.runwareApiKey ||
config.falApiKey ||
config.apiKey ||
process.env.RUNWARE_API_KEY ||
process.env.RUNWARE_KEY ||
process.env.BFL_API_KEY ||
process.env.BFL_KEY ||
""
).trim();
if (!runwareApiKey) {
if (!bflApiKey) {
return Response.json(
{
code: "RUNWARE_KEY_MISSING",
message: "未配置 Runware API Key(请在服务器环境变量或设置中配置)",
code: "BFL_KEY_MISSING",
message: "未配置 BFL API Key(请在服务器环境变量或设置中配置)",
},
{ status: 500 }
);
@ -138,13 +140,20 @@ export async function GET() {
);
const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
const modelId = String(
config.runwareModel || process.env.RUNWARE_MODEL || defaultConfig.model.id
config.bflModel ||
config.runwareModel ||
process.env.BFL_MODEL ||
process.env.RUNWARE_MODEL ||
defaultConfig.model.id
).trim();
return Response.json({
...defaultConfig,
provider: {
type: "runware",
type: "bfl",
baseUrl:
String(config.bflBaseUrl || process.env.BFL_BASE_URL || "https://api.bfl.ai").trim() ||
"https://api.bfl.ai",
},
transport: {
throttleInterval,
@ -171,13 +180,5 @@ export async function GET() {
? config.renderMode
: "balanced",
},
control: {
model: String(config.runwareControlModel || "runware:25@1").trim() || "runware:25@1",
preprocessor:
config.runwareControlPreprocessor === "lineart" ||
config.runwareControlPreprocessor === "depth"
? config.runwareControlPreprocessor
: "canny",
},
});
}

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@ export default function CanvasRealtimePage() {
status: "completed",
title: "实时画布结果",
prompt,
model: "fal-ai/flux-2/klein/realtime",
model: "bfl/flux-2",
},
connections: [],
});

View File

@ -96,9 +96,9 @@ const defaultRealtimeConfig: RealtimeConfig = {
jpegQuality: 0.55,
},
model: {
id: "runware:101@1",
id: "flux-2-klein-4b",
defaults: {
steps: 8,
steps: 3,
cfgScale: 7,
strength: 0.32,
imageSize: "square",
@ -1414,7 +1414,7 @@ export function RealtimeCanvasDialog({
if (cancelled) return;
connectionRef.current = {
provider: "runware",
provider: "bfl",
modelId: resolvedConfig.model.id,
reconnectToken: refreshToken,
};

View File

@ -0,0 +1,159 @@
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const DEFAULT_BFL_MODEL_ID = "flux-2-klein-4b";
const DEFAULT_TIMEOUT_MS = 15_000;
export type BflCapabilityCode =
| "SUPPORTED"
| "AUTH_FAILED"
| "MODEL_INVALID"
| "UPSTREAM_ERROR";
export type BflModelProbeResult = {
supported: boolean;
code: BflCapabilityCode;
message: string;
status?: number;
};
type BflProbeParams = {
apiKey: string;
modelId?: string;
baseUrl?: string;
timeoutMs?: number;
};
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("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join("/");
}
function toErrorMessage(payload: any, fallback: string) {
const direct = String(
payload?.error?.message ||
payload?.error ||
payload?.message ||
payload?.detail ||
payload?.msg ||
""
).trim();
return direct || fallback;
}
function looksLikeModelInvalid(message: string) {
const normalized = message.toLowerCase();
return (
normalized.includes("model") &&
(normalized.includes("not found") ||
normalized.includes("invalid") ||
normalized.includes("unknown") ||
normalized.includes("does not exist"))
);
}
function hasTaskIdentity(payload: any) {
return Boolean(
(typeof payload?.id === "string" && payload.id.trim()) ||
(typeof payload?.polling_url === "string" && payload.polling_url.trim()) ||
(typeof payload?.response?.id === "string" && payload.response.id.trim()) ||
(typeof payload?.response?.polling_url === "string" &&
payload.response.polling_url.trim())
);
}
export async function probeBflModelSupport(params: BflProbeParams): Promise<BflModelProbeResult> {
const apiKey = String(params.apiKey || "").trim();
const modelId = String(params.modelId || DEFAULT_BFL_MODEL_ID).trim() || DEFAULT_BFL_MODEL_ID;
const baseUrl = normalizeBaseUrl(params.baseUrl);
const timeoutMs = Math.max(3_000, Number(params.timeoutMs || DEFAULT_TIMEOUT_MS));
if (!apiKey) {
return {
supported: false,
code: "AUTH_FAILED",
message: "缺少 BFL API Key。",
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const endpoint = `${baseUrl}/v1/${encodeModelPath(modelId)}`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
"x-key": apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
prompt: "probe image generation capability",
width: 512,
height: 512,
safety_tolerance: 2,
}),
cache: "no-store",
signal: controller.signal,
});
const payload = await response.json().catch(() => null);
const message = toErrorMessage(payload, `BFL 探针请求失败(${response.status})`);
if (response.ok && hasTaskIdentity(payload)) {
return {
supported: true,
code: "SUPPORTED",
message: "模型可用。",
status: response.status,
};
}
if (response.status === 401 || response.status === 403) {
return {
supported: false,
code: "AUTH_FAILED",
message: "BFL API Key 无效或无权限,请检查后重试。",
status: response.status,
};
}
if (response.status === 404 || looksLikeModelInvalid(message)) {
return {
supported: false,
code: "MODEL_INVALID",
message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`,
status: response.status,
};
}
return {
supported: false,
code: "UPSTREAM_ERROR",
message,
status: response.status,
};
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return {
supported: false,
code: "UPSTREAM_ERROR",
message: "BFL 探针请求超时,请稍后重试。",
};
}
return {
supported: false,
code: "UPSTREAM_ERROR",
message: error instanceof Error ? error.message : "BFL 探针请求失败。",
};
} finally {
clearTimeout(timer);
}
}