diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 4fc1c830c..72422a7dc 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -900,13 +900,19 @@ export function AdminSettingsClient() {
// 实时画布API配置状态
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
enabled: true,
+ providerType: "bfl" as "bfl" | "replicate",
bflApiKey: "",
bflBaseUrl: DEFAULT_BFL_BASE_URL,
bflModel: DEFAULT_BFL_REALTIME_MODEL,
+ replicateApiToken: "",
+ replicateModel: "black-forest-labs/flux-2-klein-4b",
fallbackEnabled: false,
+ fallbackProviderType: "bfl" as "bfl" | "replicate",
fallbackBflApiKey: "",
fallbackBflBaseUrl: DEFAULT_BFL_BASE_URL,
fallbackBflModel: DEFAULT_BFL_REALTIME_MODEL,
+ fallbackReplicateApiToken: "",
+ fallbackReplicateModel: "black-forest-labs/flux-2-klein-4b",
renderMode: "balanced" as "strict-structure" | "balanced" | "stylize",
throttleInterval: 100,
maxBuffering: 2,
@@ -1065,16 +1071,22 @@ export function AdminSettingsClient() {
if (realtimeCanvasConfigData?.setting?.value) {
const config = realtimeCanvasConfigData.setting.value as {
enabled?: boolean;
+ providerType?: "bfl" | "replicate";
bflApiKey?: string;
bflBaseUrl?: string;
bflModel?: string;
+ replicateApiToken?: string;
+ replicateModel?: string;
fallbackEnabled?: boolean;
+ fallbackProviderType?: "bfl" | "replicate";
fallbackBflApiKey?: string;
fallbackBflBaseUrl?: string;
fallbackBflModel?: string;
fallbackRunwareApiKey?: string;
fallbackFalApiKey?: string;
fallbackRunwareModel?: string;
+ fallbackReplicateApiToken?: string;
+ fallbackReplicateModel?: string;
runwareApiKey?: string;
falApiKey?: string;
runwareModel?: string;
@@ -1095,10 +1107,14 @@ export function AdminSettingsClient() {
};
setRealtimeCanvasConfig({
enabled: config.enabled ?? true,
+ providerType: config.providerType === "replicate" ? "replicate" : "bfl",
bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL,
+ replicateApiToken: config.replicateApiToken || "",
+ replicateModel: config.replicateModel || "black-forest-labs/flux-2-klein-4b",
fallbackEnabled: config.fallbackEnabled === true,
+ fallbackProviderType: config.fallbackProviderType === "replicate" ? "replicate" : "bfl",
fallbackBflApiKey:
config.fallbackBflApiKey ||
config.fallbackRunwareApiKey ||
@@ -1107,6 +1123,9 @@ export function AdminSettingsClient() {
fallbackBflBaseUrl: config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL,
fallbackBflModel:
config.fallbackBflModel || config.fallbackRunwareModel || DEFAULT_BFL_REALTIME_MODEL,
+ fallbackReplicateApiToken: config.fallbackReplicateApiToken || "",
+ fallbackReplicateModel:
+ config.fallbackReplicateModel || "black-forest-labs/flux-2-klein-4b",
renderMode:
config.renderMode === "strict-structure" || config.renderMode === "stylize"
? config.renderMode
@@ -2208,23 +2227,48 @@ export function AdminSettingsClient() {
// --- 实时画布API配置逻辑 ---
const handleSaveRealtimeCanvasConfig = async () => {
try {
+ const primaryProviderType = realtimeCanvasConfig.providerType;
const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
+ const replicateApiToken = realtimeCanvasConfig.replicateApiToken.trim();
+ const replicateModel =
+ realtimeCanvasConfig.replicateModel.trim() || "black-forest-labs/flux-2-klein-4b";
const fallbackEnabled = realtimeCanvasConfig.fallbackEnabled;
+ const fallbackProviderType = realtimeCanvasConfig.fallbackProviderType;
const fallbackBflApiKey = realtimeCanvasConfig.fallbackBflApiKey.trim();
const fallbackBflBaseUrl =
realtimeCanvasConfig.fallbackBflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const fallbackBflModel =
realtimeCanvasConfig.fallbackBflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
+ const fallbackReplicateApiToken = realtimeCanvasConfig.fallbackReplicateApiToken.trim();
+ const fallbackReplicateModel =
+ realtimeCanvasConfig.fallbackReplicateModel.trim() || "black-forest-labs/flux-2-klein-4b";
- if (realtimeCanvasConfig.enabled && !bflApiKey) {
- toast.error("启用实时画布功能需要配置 BFL API Key");
+ if (
+ realtimeCanvasConfig.enabled &&
+ ((primaryProviderType === "bfl" && !bflApiKey) ||
+ (primaryProviderType === "replicate" && !replicateApiToken))
+ ) {
+ toast.error(
+ primaryProviderType === "replicate"
+ ? "启用实时画布功能需要配置 Replicate API Token"
+ : "启用实时画布功能需要配置 BFL API Key"
+ );
return;
}
- if (realtimeCanvasConfig.enabled && fallbackEnabled && !fallbackBflApiKey) {
- toast.error("启用备用实时服务需要配置备用 BFL API Key");
+ if (
+ realtimeCanvasConfig.enabled &&
+ fallbackEnabled &&
+ ((fallbackProviderType === "bfl" && !fallbackBflApiKey) ||
+ (fallbackProviderType === "replicate" && !fallbackReplicateApiToken))
+ ) {
+ toast.error(
+ fallbackProviderType === "replicate"
+ ? "启用备用实时服务需要配置备用 Replicate API Token"
+ : "启用备用实时服务需要配置备用 BFL API Key"
+ );
return;
}
@@ -2233,9 +2277,10 @@ export function AdminSettingsClient() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- apiKey: bflApiKey,
- modelId: bflModel,
- baseUrl: bflBaseUrl,
+ providerType: primaryProviderType,
+ apiKey: primaryProviderType === "replicate" ? replicateApiToken : bflApiKey,
+ modelId: primaryProviderType === "replicate" ? replicateModel : bflModel,
+ baseUrl: primaryProviderType === "replicate" ? undefined : bflBaseUrl,
}),
});
const probeData = await probeResponse.json().catch(() => null);
@@ -2249,9 +2294,14 @@ export function AdminSettingsClient() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- apiKey: fallbackBflApiKey,
- modelId: fallbackBflModel,
- baseUrl: fallbackBflBaseUrl,
+ providerType: fallbackProviderType,
+ apiKey:
+ fallbackProviderType === "replicate"
+ ? fallbackReplicateApiToken
+ : fallbackBflApiKey,
+ modelId:
+ fallbackProviderType === "replicate" ? fallbackReplicateModel : fallbackBflModel,
+ baseUrl: fallbackProviderType === "replicate" ? undefined : fallbackBflBaseUrl,
}),
});
const fallbackProbeData = await fallbackProbeResponse.json().catch(() => null);
@@ -2269,13 +2319,19 @@ export function AdminSettingsClient() {
key: "realtime_canvas_config",
value: {
enabled: realtimeCanvasConfig.enabled,
+ providerType: primaryProviderType,
bflApiKey,
bflBaseUrl,
bflModel,
+ replicateApiToken,
+ replicateModel,
fallbackEnabled,
+ fallbackProviderType,
fallbackBflApiKey,
fallbackBflBaseUrl,
fallbackBflModel,
+ fallbackReplicateApiToken,
+ fallbackReplicateModel,
runwareApiKey: bflApiKey,
falApiKey: bflApiKey,
runwareModel: bflModel,
@@ -9129,48 +9185,109 @@ export function AdminSettingsClient() {
-
-
- setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflApiKey: e.target.value })
+
+
-
-
-
- setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflBaseUrl: e.target.value })
- }
- placeholder={DEFAULT_BFL_BASE_URL}
- disabled={!realtimeCanvasConfig.enabled}
- />
-
+ {realtimeCanvasConfig.providerType === "replicate" ? (
+ <>
+
+
+
+ setRealtimeCanvasConfig({
+ ...realtimeCanvasConfig,
+ replicateApiToken: e.target.value,
+ })
+ }
+ placeholder="r8_xxxxxxxxxxxxx"
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
-
-
-
- setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflModel: e.target.value })
- }
- placeholder={DEFAULT_BFL_REALTIME_MODEL}
- disabled={!realtimeCanvasConfig.enabled}
- />
-
+
+
+
+ setRealtimeCanvasConfig({
+ ...realtimeCanvasConfig,
+ replicateModel: e.target.value,
+ })
+ }
+ placeholder="black-forest-labs/flux-2-klein-4b"
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
+ >
+ ) : (
+ <>
+
+
+
+ setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflApiKey: e.target.value })
+ }
+ placeholder="bfl_xxxxxxxxxxxxx"
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
+
+
+
+
+ setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflBaseUrl: e.target.value })
+ }
+ placeholder={DEFAULT_BFL_BASE_URL}
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
+
+
+
+
+ setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflModel: e.target.value })
+ }
+ placeholder={DEFAULT_BFL_REALTIME_MODEL}
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
+ >
+ )}
@@ -9190,53 +9307,110 @@ export function AdminSettingsClient() {
-
-
- setRealtimeCanvasConfig({
- ...realtimeCanvasConfig,
- fallbackBflApiKey: e.target.value,
- })
+
+
-
-
-
- setRealtimeCanvasConfig({
- ...realtimeCanvasConfig,
- fallbackBflBaseUrl: e.target.value,
- })
- }
- placeholder={DEFAULT_BFL_BASE_URL}
- disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
- />
-
+ {realtimeCanvasConfig.fallbackProviderType === "replicate" ? (
+ <>
+
+
+
+ setRealtimeCanvasConfig({
+ ...realtimeCanvasConfig,
+ fallbackReplicateApiToken: e.target.value,
+ })
+ }
+ placeholder="r8_xxxxxxxxxxxxx"
+ disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
+ />
+
-
-
-
- setRealtimeCanvasConfig({
- ...realtimeCanvasConfig,
- fallbackBflModel: e.target.value,
- })
- }
- placeholder={DEFAULT_BFL_REALTIME_MODEL}
- disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
- />
-
+
+
+
+ setRealtimeCanvasConfig({
+ ...realtimeCanvasConfig,
+ fallbackReplicateModel: e.target.value,
+ })
+ }
+ placeholder="black-forest-labs/flux-2-klein-4b"
+ disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
+ />
+
+ >
+ ) : (
+ <>
+
+
+
+ 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/realtime/model-capability/route.ts b/app/api/admin/realtime/model-capability/route.ts
index d9166755d..6d392a7e6 100644
--- a/app/api/admin/realtime/model-capability/route.ts
+++ b/app/api/admin/realtime/model-capability/route.ts
@@ -2,8 +2,11 @@ import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
+import { probeReplicateModelSupport } from "@/lib/realtime/replicate-capability";
+import type { RealtimeProviderType } from "@/lib/realtime/config";
const schema = z.object({
+ providerType: z.enum(["bfl", "replicate"]).optional(),
apiKey: z.string().min(1),
modelId: z.string().min(1),
baseUrl: z.string().optional(),
@@ -20,11 +23,19 @@ export async function POST(request: Request) {
const body = await request.json();
const data = schema.parse(body);
- const result = await probeBflModelSupport({
- apiKey: data.apiKey,
- modelId: data.modelId,
- baseUrl: data.baseUrl,
- });
+ const providerType: RealtimeProviderType = data.providerType === "replicate" ? "replicate" : "bfl";
+ const result =
+ providerType === "replicate"
+ ? await probeReplicateModelSupport({
+ apiKey: data.apiKey,
+ modelId: data.modelId,
+ baseUrl: data.baseUrl,
+ })
+ : await probeBflModelSupport({
+ apiKey: data.apiKey,
+ modelId: data.modelId,
+ baseUrl: data.baseUrl,
+ });
if (!result.supported) {
return Response.json(result, { status: mapStatus(result.code) });
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
index faaae2080..18f87b7a2 100644
--- a/app/api/admin/settings/route.ts
+++ b/app/api/admin/settings/route.ts
@@ -7,6 +7,7 @@ import {
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
+import { probeReplicateModelSupport } from "@/lib/realtime/replicate-capability";
import {
normalizeRealtimeCanvasConfigForStorage,
resolveRealtimeProviders,
@@ -72,11 +73,18 @@ export async function POST(request: Request) {
}
for (const provider of providers) {
- const probeResult = await probeBflModelSupport({
- apiKey: provider.apiKey,
- modelId: provider.model,
- baseUrl: provider.baseUrl,
- });
+ const probeResult =
+ provider.type === "replicate"
+ ? await probeReplicateModelSupport({
+ apiKey: provider.apiKey,
+ modelId: provider.model,
+ baseUrl: provider.baseUrl,
+ })
+ : await probeBflModelSupport({
+ apiKey: provider.apiKey,
+ modelId: provider.model,
+ baseUrl: provider.baseUrl,
+ });
if (!probeResult.supported) {
return Response.json(
diff --git a/app/api/realtime/config/route.ts b/app/api/realtime/config/route.ts
index 99060e0b9..a06fad509 100644
--- a/app/api/realtime/config/route.ts
+++ b/app/api/realtime/config/route.ts
@@ -117,7 +117,7 @@ export async function GET() {
return Response.json({
...defaultConfig,
provider: {
- type: "bfl",
+ type: activeProvider.type,
baseUrl: activeProvider.baseUrl || DEFAULT_BFL_BASE_URL,
},
failover: {
@@ -125,7 +125,7 @@ export async function GET() {
fallbackProvider:
providers.length > 1
? {
- type: "bfl",
+ type: providers[1].type,
baseUrl: providers[1].baseUrl,
model: providers[1].model,
}
diff --git a/app/api/realtime/generate/route.ts b/app/api/realtime/generate/route.ts
index cd77e243c..6005d7f1a 100644
--- a/app/api/realtime/generate/route.ts
+++ b/app/api/realtime/generate/route.ts
@@ -2,6 +2,8 @@ import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
DEFAULT_BFL_MODEL,
+ DEFAULT_REPLICATE_BASE_URL,
+ DEFAULT_REPLICATE_MODEL,
RealtimeCanvasConfig,
RealtimeProviderConfig,
resolveRealtimeProviders,
@@ -23,6 +25,9 @@ type GeneratePayload = {
prompt?: string;
seed?: number;
imageSize?: "square" | "square_hd";
+ steps?: number;
+ cfgScale?: number;
+ strength?: number;
};
type BflSubmitResponse = {
@@ -39,6 +44,17 @@ type BflSubmitResponse = {
message?: string;
};
+type ReplicatePredictionResponse = {
+ id?: string;
+ status?: string;
+ output?: unknown;
+ error?: unknown;
+ detail?: unknown;
+ urls?: {
+ get?: string;
+ };
+};
+
const FALLBACK_REALTIME_PROMPT =
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
@@ -146,6 +162,159 @@ function getBflImageFieldCandidates() {
return ["input_image", "image_prompt"] as const;
}
+function getReplicateImageFieldCandidates(imagePrompt: string) {
+ return [
+ { images: [imagePrompt] },
+ { image: imagePrompt },
+ { input_image: imagePrompt },
+ ] as const;
+}
+
+function isSelfHostedRealtimeProvider(provider: RealtimeProviderConfig) {
+ return !provider.baseUrl.includes("api.bfl.ai");
+}
+
+function splitReplicateModelIdentifier(modelId: string) {
+ const [owner, ...rest] = modelId.split("/").filter(Boolean);
+ const name = rest.join("/");
+ if (!owner || !name) {
+ throw new Error(`无效的 Replicate 模型标识:${modelId}`);
+ }
+ return { owner, name };
+}
+
+function extractReplicateImageUrl(payload: any): string {
+ const output = payload?.output;
+ const candidates = [
+ Array.isArray(output) ? output[0] : output,
+ payload?.urls?.stream,
+ payload?.result,
+ ];
+
+ for (const candidate of candidates) {
+ const value = String(candidate || "").trim();
+ if (!value) continue;
+ if (value.startsWith("data:")) return value;
+ if (/^https?:\/\//i.test(value)) return value;
+ }
+
+ return "";
+}
+
+function extractReplicateErrorMessage(payload: any, fallback: string) {
+ const message = String(payload?.detail || payload?.error || payload?.message || "").trim();
+ return message || fallback;
+}
+
+async function submitReplicatePrediction(params: {
+ apiKey: string;
+ model: string;
+ input: Record
;
+}): Promise {
+ const { owner, name } = splitReplicateModelIdentifier(params.model);
+ const endpoint = `${DEFAULT_REPLICATE_BASE_URL}/models/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/predictions`;
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ "Content-Type": "application/json",
+ Prefer: "wait=2",
+ },
+ body: JSON.stringify({ input: params.input }),
+ cache: "no-store",
+ });
+ const payload = (await response.json().catch(() => null)) as ReplicatePredictionResponse | null;
+ const imageUrl = extractReplicateImageUrl(payload);
+ const taskId = String(payload?.id || "").trim();
+ const pollingUrl = String(payload?.urls?.get || "").trim();
+
+ if (response.ok && (imageUrl || taskId || pollingUrl)) {
+ return {
+ taskId,
+ pollingUrl,
+ imageUrl,
+ errorMessage: "",
+ status: response.status,
+ };
+ }
+
+ return {
+ taskId,
+ pollingUrl,
+ imageUrl,
+ errorMessage: extractReplicateErrorMessage(payload, `Replicate 请求失败(${response.status})`),
+ status: response.status,
+ };
+}
+
+async function pollReplicateResult(params: {
+ apiKey: string;
+ pollingUrl?: string;
+ taskId?: string;
+ timeoutMs?: number;
+}) {
+ const timeoutMs = Math.max(10_000, Number(params.timeoutMs || 60_000));
+ const deadline = Date.now() + timeoutMs;
+ let lastMessage = "";
+
+ while (Date.now() < deadline) {
+ const taskId = String(params.taskId || "").trim();
+ const url =
+ String(params.pollingUrl || "").trim() ||
+ `${DEFAULT_REPLICATE_BASE_URL}/predictions/${encodeURIComponent(taskId)}`;
+
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: "application/json",
+ },
+ cache: "no-store",
+ });
+
+ const payload = (await response.json().catch(() => null)) as ReplicatePredictionResponse | null;
+ const imageUrl = extractReplicateImageUrl(payload);
+ if (imageUrl) return { imageUrl, errorMessage: "", status: response.status };
+
+ const status = String(payload?.status || "").trim().toLowerCase();
+ if (status) {
+ lastMessage = status;
+ }
+
+ if (status === "succeeded") {
+ return {
+ imageUrl: "",
+ errorMessage: "Replicate 返回成功状态但未携带图像结果",
+ status: response.status,
+ };
+ }
+
+ if (status === "failed" || status === "canceled") {
+ return {
+ imageUrl: "",
+ errorMessage: extractReplicateErrorMessage(payload, "Replicate 任务执行失败"),
+ status: response.status,
+ };
+ }
+
+ if (!response.ok) {
+ return {
+ imageUrl: "",
+ errorMessage: extractReplicateErrorMessage(payload, `Replicate 轮询失败(${response.status})`),
+ status: response.status,
+ };
+ }
+
+ await delay(250);
+ }
+
+ return {
+ imageUrl: "",
+ errorMessage: `Replicate 轮询超时${lastMessage ? `(${lastMessage})` : ""}`,
+ status: 504,
+ };
+}
+
type SubmitTaskResult = {
taskId: string;
pollingUrl: string;
@@ -298,33 +467,75 @@ async function generateWithProvider(params: {
prompt: string;
size: number;
seed?: number;
+ body: GeneratePayload;
+ config: RealtimeCanvasConfig;
}): Promise {
- const imageFieldCandidates = getBflImageFieldCandidates();
+ const isReplicate = params.provider.type === "replicate";
+ const imageFieldCandidates = isReplicate
+ ? getReplicateImageFieldCandidates(params.imagePrompt)
+ : getBflImageFieldCandidates();
- let selectedImageField: "input_image" | "image_prompt" = imageFieldCandidates[0];
+ let selectedImageField: "input_image" | "image_prompt" = "input_image";
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 } : {}),
- };
+ if (isReplicate) {
+ const candidate = getReplicateImageFieldCandidates(params.imagePrompt)[index];
+ const requestBody: Record = {
+ prompt: params.prompt,
+ ...(typeof params.seed === "number" ? { seed: params.seed } : {}),
+ ...candidate,
+ };
- submitResult = await submitBflTask({
- apiKey: params.provider.apiKey,
- baseUrl: params.provider.baseUrl,
- model: params.provider.model,
- body: requestBody,
- });
+ submitResult = await submitReplicatePrediction({
+ apiKey: params.provider.apiKey,
+ model: params.provider.model || DEFAULT_REPLICATE_MODEL,
+ input: requestBody,
+ });
+ } else {
+ selectedImageField = imageFieldCandidates[index] as "input_image" | "image_prompt";
+ 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 } : {}),
+ };
- if (!submitResult.errorMessage) {
+ if (isSelfHostedRealtimeProvider(params.provider)) {
+ requestBody.steps = clamp(Number(params.body.steps ?? params.config.defaults?.steps ?? 1), 1, 6);
+ requestBody.cfg_scale = clamp(
+ Number(params.body.cfgScale ?? params.config.defaults?.cfgScale ?? 1),
+ 1,
+ 14
+ );
+ requestBody.strength = clamp(
+ Number(params.body.strength ?? params.config.defaults?.strength ?? 0.32),
+ 0.05,
+ 1
+ );
+ requestBody.preview_megapixels = clamp(Number(params.config.previewMegapixels ?? 0.4), 0.2, 0.8);
+ requestBody.detail_megapixels = clamp(Number(params.config.detailMegapixels ?? 1.0), 0.6, 1.2);
+ requestBody.output_jpeg_quality = clamp(
+ Number(params.config.outputJpegQuality ?? 62),
+ 35,
+ 90
+ );
+ requestBody.quality_profile = params.body.qualityProfile || "refine";
+ }
+
+ submitResult = await submitBflTask({
+ apiKey: params.provider.apiKey,
+ baseUrl: params.provider.baseUrl,
+ model: params.provider.model,
+ body: requestBody,
+ });
+ }
+
+ if (!submitResult?.errorMessage) {
break;
}
@@ -338,8 +549,8 @@ async function generateWithProvider(params: {
return {
ok: false,
- message: submitResult.errorMessage || "实时生成提交失败",
- status: submitResult.status || 502,
+ message: submitResult?.errorMessage || "实时生成提交失败",
+ status: submitResult?.status || 502,
provider: params.provider,
};
}
@@ -355,13 +566,21 @@ async function generateWithProvider(params: {
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,
- });
+ const pollResult =
+ params.provider.type === "replicate"
+ ? await pollReplicateResult({
+ apiKey: params.provider.apiKey,
+ pollingUrl: submitResult.pollingUrl,
+ taskId: submitResult.taskId,
+ timeoutMs: 60_000,
+ })
+ : 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,
@@ -459,6 +678,8 @@ export async function POST(request: Request) {
prompt: positivePrompt,
size,
seed,
+ body,
+ config,
});
if (!result.ok) {
diff --git a/lib/realtime/config.ts b/lib/realtime/config.ts
index 20d600592..140e8206d 100644
--- a/lib/realtime/config.ts
+++ b/lib/realtime/config.ts
@@ -1,8 +1,13 @@
export const DEFAULT_BFL_MODEL = "flux-2-klein-4b";
export const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
+export const DEFAULT_REPLICATE_MODEL = "black-forest-labs/flux-2-klein-4b";
+export const DEFAULT_REPLICATE_BASE_URL = "https://api.replicate.com/v1";
+
+export type RealtimeProviderType = "bfl" | "replicate";
export type RealtimeCanvasConfig = {
enabled?: boolean;
+ providerType?: RealtimeProviderType;
bflApiKey?: string;
bflModel?: string;
bflBaseUrl?: string;
@@ -10,7 +15,10 @@ export type RealtimeCanvasConfig = {
runwareModel?: string;
falApiKey?: string;
apiKey?: string;
+ replicateApiToken?: string;
+ replicateModel?: string;
fallbackEnabled?: boolean;
+ fallbackProviderType?: RealtimeProviderType;
fallbackBflApiKey?: string;
fallbackBflModel?: string;
fallbackBflBaseUrl?: string;
@@ -18,6 +26,8 @@ export type RealtimeCanvasConfig = {
fallbackRunwareModel?: string;
fallbackFalApiKey?: string;
fallbackApiKey?: string;
+ fallbackReplicateApiToken?: string;
+ fallbackReplicateModel?: string;
negativePrompt?: string;
renderMode?: "strict-structure" | "balanced" | "stylize";
strictSketch?: boolean;
@@ -25,6 +35,9 @@ export type RealtimeCanvasConfig = {
maxBuffering?: number;
maxInputResolution?: number;
jpegQuality?: number;
+ previewMegapixels?: number;
+ detailMegapixels?: number;
+ outputJpegQuality?: number;
defaults?: {
steps?: number;
cfgScale?: number;
@@ -36,6 +49,7 @@ export type RealtimeCanvasConfig = {
};
export type RealtimeProviderConfig = {
+ type: RealtimeProviderType;
apiKey: string;
baseUrl: string;
model: string;
@@ -47,6 +61,10 @@ function trimString(value: unknown) {
return String(value || "").trim();
}
+function normalizeProviderType(raw: unknown): RealtimeProviderType {
+ return raw === "replicate" ? "replicate" : "bfl";
+}
+
export function normalizeBaseUrl(raw: string | undefined) {
const next = trimString(raw || DEFAULT_BFL_BASE_URL);
return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, "");
@@ -64,7 +82,9 @@ function hasFallbackIntent(config: RealtimeCanvasConfig) {
config.fallbackApiKey ||
config.fallbackBflBaseUrl ||
config.fallbackBflModel ||
- config.fallbackRunwareModel
+ config.fallbackRunwareModel ||
+ config.fallbackReplicateApiToken ||
+ config.fallbackReplicateModel
)
);
}
@@ -73,6 +93,22 @@ export function resolvePrimaryRealtimeProvider(
config: RealtimeCanvasConfig,
env: NodeJS.ProcessEnv = process.env
): RealtimeProviderConfig | null {
+ const type = normalizeProviderType(config.providerType);
+
+ if (type === "replicate") {
+ const apiKey = trimString(config.replicateApiToken || env.REPLICATE_API_TOKEN);
+ if (!apiKey) return null;
+
+ return {
+ type,
+ apiKey,
+ model: trimString(config.replicateModel) || DEFAULT_REPLICATE_MODEL,
+ baseUrl: DEFAULT_REPLICATE_BASE_URL,
+ source: "primary",
+ label: "主实时服务",
+ };
+ }
+
const apiKey = trimString(
config.bflApiKey ||
config.runwareApiKey ||
@@ -89,6 +125,7 @@ export function resolvePrimaryRealtimeProvider(
const baseUrl = normalizeBaseUrl(config.bflBaseUrl || env.BFL_BASE_URL);
return {
+ type,
apiKey,
model,
baseUrl,
@@ -102,6 +139,22 @@ export function resolveFallbackRealtimeProvider(
): RealtimeProviderConfig | null {
if (!hasFallbackIntent(config)) return null;
+ const type = normalizeProviderType(config.fallbackProviderType);
+
+ if (type === "replicate") {
+ const apiKey = trimString(config.fallbackReplicateApiToken);
+ if (!apiKey) return null;
+
+ return {
+ type,
+ apiKey,
+ model: trimString(config.fallbackReplicateModel) || DEFAULT_REPLICATE_MODEL,
+ baseUrl: DEFAULT_REPLICATE_BASE_URL,
+ source: "fallback",
+ label: "备用实时服务",
+ };
+ }
+
const apiKey = trimString(
config.fallbackBflApiKey ||
config.fallbackRunwareApiKey ||
@@ -115,6 +168,7 @@ export function resolveFallbackRealtimeProvider(
const baseUrl = normalizeBaseUrl(config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL);
return {
+ type,
apiKey,
model,
baseUrl,
@@ -166,21 +220,82 @@ export function normalizeRealtimeCanvasConfigForStorage(
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,
+ providerType: primary?.type || normalizeProviderType(value.providerType),
+ bflApiKey:
+ primary?.type === "bfl"
+ ? primary.apiKey
+ : trimString((value as RealtimeCanvasConfig).bflApiKey),
+ bflModel:
+ primary?.type === "bfl"
+ ? primary.model
+ : trimString((value as RealtimeCanvasConfig).bflModel) || DEFAULT_BFL_MODEL,
+ bflBaseUrl:
+ primary?.type === "bfl"
+ ? primary.baseUrl
+ : normalizeBaseUrl((value as RealtimeCanvasConfig).bflBaseUrl || env.BFL_BASE_URL),
+ runwareApiKey:
+ primary?.type === "bfl"
+ ? primary.apiKey
+ : trimString((value as RealtimeCanvasConfig).runwareApiKey),
+ falApiKey:
+ primary?.type === "bfl"
+ ? primary.apiKey
+ : trimString((value as RealtimeCanvasConfig).falApiKey),
+ apiKey:
+ primary?.type === "bfl"
+ ? primary.apiKey
+ : trimString((value as RealtimeCanvasConfig).apiKey),
+ runwareModel:
+ primary?.type === "bfl"
+ ? primary.model
+ : trimString((value as RealtimeCanvasConfig).runwareModel) || DEFAULT_BFL_MODEL,
+ replicateApiToken:
+ primary?.type === "replicate"
+ ? primary.apiKey
+ : trimString((value as RealtimeCanvasConfig).replicateApiToken),
+ replicateModel:
+ primary?.type === "replicate"
+ ? primary.model
+ : trimString((value as RealtimeCanvasConfig).replicateModel) || DEFAULT_REPLICATE_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,
+ fallbackProviderType: fallback?.type || normalizeProviderType(value.fallbackProviderType),
+ fallbackBflApiKey:
+ fallback?.type === "bfl"
+ ? fallback.apiKey
+ : trimString((value as RealtimeCanvasConfig).fallbackBflApiKey),
+ fallbackBflModel:
+ fallback?.type === "bfl"
+ ? fallback.model
+ : trimString((value as RealtimeCanvasConfig).fallbackBflModel) || DEFAULT_BFL_MODEL,
+ fallbackBflBaseUrl:
+ fallback?.type === "bfl"
+ ? fallback.baseUrl
+ : normalizeBaseUrl((value as RealtimeCanvasConfig).fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL),
+ fallbackRunwareApiKey:
+ fallback?.type === "bfl"
+ ? fallback.apiKey
+ : trimString((value as RealtimeCanvasConfig).fallbackRunwareApiKey),
+ fallbackFalApiKey:
+ fallback?.type === "bfl"
+ ? fallback.apiKey
+ : trimString((value as RealtimeCanvasConfig).fallbackFalApiKey),
+ fallbackApiKey:
+ fallback?.type === "bfl"
+ ? fallback.apiKey
+ : trimString((value as RealtimeCanvasConfig).fallbackApiKey),
+ fallbackRunwareModel:
+ fallback?.type === "bfl"
+ ? fallback.model
+ : trimString((value as RealtimeCanvasConfig).fallbackRunwareModel) || DEFAULT_BFL_MODEL,
+ fallbackReplicateApiToken:
+ fallback?.type === "replicate"
+ ? fallback.apiKey
+ : trimString((value as RealtimeCanvasConfig).fallbackReplicateApiToken),
+ fallbackReplicateModel:
+ fallback?.type === "replicate"
+ ? fallback.model
+ : trimString((value as RealtimeCanvasConfig).fallbackReplicateModel) ||
+ DEFAULT_REPLICATE_MODEL,
renderMode,
};
}
diff --git a/lib/realtime/replicate-capability.ts b/lib/realtime/replicate-capability.ts
new file mode 100644
index 000000000..146611547
--- /dev/null
+++ b/lib/realtime/replicate-capability.ts
@@ -0,0 +1,232 @@
+import sharp from "sharp";
+
+const DEFAULT_REPLICATE_BASE_URL = "https://api.replicate.com/v1";
+const DEFAULT_REPLICATE_MODEL = "black-forest-labs/flux-2-klein-4b";
+const DEFAULT_TIMEOUT_MS = 20_000;
+
+export type ReplicateCapabilityCode =
+ | "SUPPORTED"
+ | "AUTH_FAILED"
+ | "MODEL_INVALID"
+ | "UPSTREAM_ERROR";
+
+export type ReplicateModelProbeResult = {
+ supported: boolean;
+ code: ReplicateCapabilityCode;
+ message: string;
+ status?: number;
+};
+
+type ReplicateProbeParams = {
+ apiKey: string;
+ modelId?: string;
+ baseUrl?: string;
+ timeoutMs?: number;
+};
+
+let cachedProbeImageDataUri: string | null = null;
+
+function trimString(value: unknown) {
+ return String(value || "").trim();
+}
+
+function normalizeBaseUrl(raw: string | undefined) {
+ const next = trimString(raw || DEFAULT_REPLICATE_BASE_URL);
+ return (next || DEFAULT_REPLICATE_BASE_URL).replace(/\/+$/, "");
+}
+
+function toErrorMessage(payload: any, fallback: string) {
+ const direct = trimString(
+ payload?.detail ||
+ payload?.error ||
+ payload?.message ||
+ payload?.title ||
+ payload?.input?.message ||
+ payload?.non_field_errors?.[0]
+ );
+ return direct || fallback;
+}
+
+function isModelInvalid(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 splitModelIdentifier(modelId: string) {
+ const [owner, ...rest] = modelId.split("/").filter(Boolean);
+ const name = rest.join("/");
+ if (!owner || !name) {
+ throw new Error(`无效的 Replicate 模型标识:${modelId}`);
+ }
+ return { owner, name };
+}
+
+function shouldRetryWithAltField(message: string) {
+ const normalized = message.toLowerCase();
+ return (
+ normalized.includes("images") ||
+ normalized.includes("image") ||
+ normalized.includes("input_image") ||
+ normalized.includes("schema") ||
+ normalized.includes("validation")
+ );
+}
+
+async function getProbeImageDataUri() {
+ if (cachedProbeImageDataUri) return cachedProbeImageDataUri;
+
+ const buffer = await sharp({
+ create: {
+ width: 64,
+ height: 64,
+ channels: 3,
+ background: { r: 255, g: 255, b: 255 },
+ },
+ })
+ .png()
+ .toBuffer();
+
+ cachedProbeImageDataUri = `data:image/png;base64,${buffer.toString("base64")}`;
+ return cachedProbeImageDataUri;
+}
+
+async function createPrediction(params: {
+ apiKey: string;
+ baseUrl: string;
+ modelId: string;
+ input: Record;
+ waitSeconds?: number;
+ signal?: AbortSignal;
+}) {
+ const { owner, name } = splitModelIdentifier(params.modelId);
+ const endpoint = `${params.baseUrl}/models/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/predictions`;
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ "Content-Type": "application/json",
+ Prefer: `wait=${Math.max(1, Number(params.waitSeconds || 1))}`,
+ },
+ body: JSON.stringify({ input: params.input }),
+ cache: "no-store",
+ signal: params.signal,
+ });
+
+ const payload = await response.json().catch(() => null);
+ return { response, payload };
+}
+
+export async function probeReplicateModelSupport(
+ params: ReplicateProbeParams
+): Promise {
+ const apiKey = trimString(params.apiKey);
+ const modelId = trimString(params.modelId || DEFAULT_REPLICATE_MODEL) || DEFAULT_REPLICATE_MODEL;
+ const baseUrl = normalizeBaseUrl(params.baseUrl);
+
+ if (!apiKey) {
+ return {
+ supported: false,
+ code: "AUTH_FAILED",
+ message: "缺少 Replicate API Token。",
+ };
+ }
+
+ const controller = new AbortController();
+ const timer = setTimeout(
+ () => controller.abort(),
+ Math.max(3_000, Number(params.timeoutMs || DEFAULT_TIMEOUT_MS))
+ );
+
+ try {
+ const image = await getProbeImageDataUri();
+ const candidateInputs = [
+ { prompt: "white square photo", images: [image] },
+ { prompt: "white square photo", image },
+ { prompt: "white square photo", input_image: image },
+ ];
+
+ for (let index = 0; index < candidateInputs.length; index += 1) {
+ const { response, payload } = await createPrediction({
+ apiKey,
+ baseUrl,
+ modelId,
+ input: candidateInputs[index],
+ waitSeconds: 1,
+ signal: controller.signal,
+ });
+
+ const message = toErrorMessage(payload, `Replicate 探针请求失败(${response.status})`);
+
+ if (
+ response.ok &&
+ (trimString(payload?.id) ||
+ trimString(payload?.status) ||
+ trimString(payload?.urls?.get))
+ ) {
+ return {
+ supported: true,
+ code: "SUPPORTED",
+ message: "模型可用。",
+ status: response.status,
+ };
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ return {
+ supported: false,
+ code: "AUTH_FAILED",
+ message: "Replicate API Token 无效或无权限,请检查后重试。",
+ status: response.status,
+ };
+ }
+
+ if (response.status === 404 || isModelInvalid(message)) {
+ return {
+ supported: false,
+ code: "MODEL_INVALID",
+ message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`,
+ status: response.status,
+ };
+ }
+
+ if (index < candidateInputs.length - 1 && shouldRetryWithAltField(message)) {
+ continue;
+ }
+
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message,
+ status: response.status,
+ };
+ }
+
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message: "Replicate 探针请求失败。",
+ };
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message: "Replicate 探针请求超时,请稍后重试。",
+ };
+ }
+
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message: error instanceof Error ? error.message : "Replicate 探针请求失败。",
+ };
+ } finally {
+ clearTimeout(timer);
+ }
+}