Add BFL image runtime provider
This commit is contained in:
parent
dbfc8e97d2
commit
ccc2e45b12
@ -457,6 +457,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
}
|
||||
if (
|
||||
normalized === "alibaba-image" ||
|
||||
normalized === "bfl-image" ||
|
||||
normalized === "ruxa-image" ||
|
||||
normalized === "fal-image" ||
|
||||
normalized === "openai-image" ||
|
||||
@ -1375,6 +1376,7 @@ function pickAgentSourceImageUrls(params: {
|
||||
|
||||
const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"alibaba-image",
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"openai-image",
|
||||
@ -2015,6 +2017,7 @@ function ensureImageAnalysisSummarySection(message: string, summary: string): st
|
||||
*/
|
||||
async function convertMessagesWithImages(messages: ChatMessage[], providerType: string) {
|
||||
if (
|
||||
providerType === "bfl-image" ||
|
||||
providerType === "ruxa-image" ||
|
||||
providerType === "fal-image" ||
|
||||
providerType === "openai-image" ||
|
||||
@ -2177,6 +2180,8 @@ function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
|
||||
originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-");
|
||||
} else if (thumbnailUrl.includes("volcano-thumb-")) {
|
||||
originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-");
|
||||
} else if (thumbnailUrl.includes("bfl-image-thumb-")) {
|
||||
originalUrl = thumbnailUrl.replace("bfl-image-thumb-", "bfl-image-orig-");
|
||||
} else if (thumbnailUrl.includes("alibaba-image-thumb-")) {
|
||||
originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-");
|
||||
} else if (thumbnailUrl.includes("ruxa-image-thumb-")) {
|
||||
@ -7976,6 +7981,7 @@ export async function POST(request: Request) {
|
||||
isImageGenerationModel &&
|
||||
[
|
||||
"alibaba-image",
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"openai-image",
|
||||
@ -8061,7 +8067,9 @@ export async function POST(request: Request) {
|
||||
const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => {
|
||||
// 转换消息(图片编辑模型统一使用 URL 输入)
|
||||
const providerForConvert =
|
||||
(languageModel as any).provider === "ruxa-image"
|
||||
(languageModel as any).provider === "bfl-image"
|
||||
? "bfl-image"
|
||||
: (languageModel as any).provider === "ruxa-image"
|
||||
? "ruxa-image"
|
||||
: (languageModel as any).provider === "fal-image"
|
||||
? "fal-image"
|
||||
@ -8140,10 +8148,11 @@ export async function POST(request: Request) {
|
||||
const providerPriority: Record<string, number> = {
|
||||
"volcano-vision": 0,
|
||||
"alibaba-image": 1,
|
||||
"ruxa-image": 2,
|
||||
"fal-image": 3,
|
||||
"openai-image": 4,
|
||||
runway: 5,
|
||||
"bfl-image": 2,
|
||||
"ruxa-image": 3,
|
||||
"fal-image": 4,
|
||||
"openai-image": 5,
|
||||
runway: 6,
|
||||
gemini: 6,
|
||||
"huanke-gemini": 7,
|
||||
};
|
||||
@ -8166,7 +8175,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const convertProvider =
|
||||
candidate.provider.type === "ruxa-image"
|
||||
candidate.provider.type === "bfl-image"
|
||||
? "bfl-image"
|
||||
: candidate.provider.type === "ruxa-image"
|
||||
? "ruxa-image"
|
||||
: candidate.provider.type === "fal-image"
|
||||
? "fal-image"
|
||||
@ -8531,10 +8542,11 @@ export async function POST(request: Request) {
|
||||
if (!fallbackSuccess) {
|
||||
const providerPriority: Record<string, number> = {
|
||||
"alibaba-image": 0,
|
||||
"ruxa-image": 1,
|
||||
"fal-image": 2,
|
||||
"openai-image": 3,
|
||||
runway: 4,
|
||||
"bfl-image": 1,
|
||||
"ruxa-image": 2,
|
||||
"fal-image": 3,
|
||||
"openai-image": 4,
|
||||
runway: 5,
|
||||
gemini: 5,
|
||||
"huanke-gemini": 6,
|
||||
"volcano-vision": 7,
|
||||
@ -8558,7 +8570,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const convertProvider =
|
||||
candidate.provider.type === "ruxa-image"
|
||||
candidate.provider.type === "bfl-image"
|
||||
? "bfl-image"
|
||||
: candidate.provider.type === "ruxa-image"
|
||||
? "ruxa-image"
|
||||
: candidate.provider.type === "fal-image"
|
||||
? "fal-image"
|
||||
@ -9352,10 +9366,11 @@ export async function POST(request: Request) {
|
||||
const providerPriority: Record<string, number> = {
|
||||
"volcano-vision": 0,
|
||||
"alibaba-image": 1,
|
||||
"ruxa-image": 2,
|
||||
"fal-image": 3,
|
||||
"openai-image": 4,
|
||||
runway: 5,
|
||||
"bfl-image": 2,
|
||||
"ruxa-image": 3,
|
||||
"fal-image": 4,
|
||||
"openai-image": 5,
|
||||
runway: 6,
|
||||
gemini: 6,
|
||||
"huanke-gemini": 7,
|
||||
};
|
||||
@ -9383,7 +9398,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const convertProvider =
|
||||
candidate.provider.type === "ruxa-image"
|
||||
candidate.provider.type === "bfl-image"
|
||||
? "bfl-image"
|
||||
: candidate.provider.type === "ruxa-image"
|
||||
? "ruxa-image"
|
||||
: candidate.provider.type === "fal-image"
|
||||
? "fal-image"
|
||||
|
||||
@ -18,6 +18,7 @@ import { createHuankeGemini } from "./providers/huanke-gemini";
|
||||
import { createQianwenVideo } from "./providers/qianwen-video";
|
||||
import { createVolcanoVision } from "./providers/volcano-vision";
|
||||
import { createAlibabaImage } from "./providers/alibaba-image";
|
||||
import { createBflImage } from "./providers/bfl-image";
|
||||
import { createRuxaImage } from "./providers/ruxa-image";
|
||||
import { createFalImage } from "./providers/fal-image";
|
||||
import { createOpenAIImage } from "./providers/openai-image";
|
||||
@ -190,6 +191,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
return alibabaImageProvider as any;
|
||||
}
|
||||
|
||||
case "bfl-image": {
|
||||
const bflImageProvider = createBflImage({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
console.log(`[Provider] Created BFL Image generation provider: ${resolvedBaseUrl}`);
|
||||
return bflImageProvider as any;
|
||||
}
|
||||
|
||||
case "ruxa-image": {
|
||||
// RUXA 图片编辑服务
|
||||
const ruxaImageProvider = createRuxaImage({
|
||||
@ -344,6 +354,10 @@ export async function getDynamicModel(
|
||||
console.log("[getDynamicModel] Getting Alibaba Image chat model..."); // 调试日志
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
console.log("[getDynamicModel] Alibaba Image model retrieved:", baseModel ? "success" : "failed"); // 调试日志
|
||||
} else if (provider.type === "bfl-image") {
|
||||
console.log("[getDynamicModel] Getting BFL Image chat model...");
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
console.log("[getDynamicModel] BFL Image model retrieved:", baseModel ? "success" : "failed");
|
||||
} else if (provider.type === "ruxa-image") {
|
||||
// RUXA 图片编辑生成使用 chat() 方法
|
||||
console.log("[getDynamicModel] Getting Ruxa Image chat model..."); // 调试日志
|
||||
|
||||
@ -10,6 +10,7 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
|
||||
"gemini",
|
||||
"huanke-gemini",
|
||||
"alibaba-image",
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"openai-image",
|
||||
|
||||
@ -48,6 +48,7 @@ const CHAT_PROVIDER_TYPES = [
|
||||
|
||||
const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
||||
"alibaba-image",
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"openai-image",
|
||||
@ -154,7 +155,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
> = {
|
||||
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
|
||||
image_edit: {
|
||||
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini"],
|
||||
requireVision: true,
|
||||
requireImageGen: true,
|
||||
description: "图片编辑",
|
||||
@ -169,7 +170,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 图片生成:优先灵魂4.5
|
||||
image_gen: {
|
||||
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
requireImageGen: true,
|
||||
description: "图片生成",
|
||||
},
|
||||
@ -197,25 +198,25 @@ const SUB_INTENT_OVERRIDES: Record<
|
||||
> = {
|
||||
// 抠图:alibaba-image 效果最好
|
||||
remove_background: {
|
||||
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
|
||||
reason: "抠图首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 换背景:alibaba-image 支持场景生成
|
||||
replace_background: {
|
||||
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
|
||||
reason: "换背景首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 图片增强
|
||||
enhance: {
|
||||
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "volcano-vision"],
|
||||
reason: "增强首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 风格迁移:gemini 可能更有创意
|
||||
style_transfer: {
|
||||
providerTypes: ["gemini", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway"],
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway"],
|
||||
reason: "风格迁移优选Gemini",
|
||||
},
|
||||
};
|
||||
|
||||
478
lib/ai/providers/bfl-image.ts
Normal file
478
lib/ai/providers/bfl-image.ts
Normal file
@ -0,0 +1,478 @@
|
||||
import "server-only";
|
||||
|
||||
import sharp from "sharp";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
interface BflImageConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
type UploadedImage = {
|
||||
thumbnail: string;
|
||||
original: string;
|
||||
};
|
||||
|
||||
type BflImageParams = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
type BflSubmitResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
task_status?: string;
|
||||
polling_url?: string;
|
||||
response?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
task_status?: string;
|
||||
polling_url?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
result?: unknown;
|
||||
sample?: string;
|
||||
output?: unknown;
|
||||
images?: unknown;
|
||||
image_url?: string;
|
||||
error?: unknown;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 180000;
|
||||
const SUPPORTED_ASPECT_RATIOS = [
|
||||
"1:1",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"4:5",
|
||||
"5:4",
|
||||
"9:16",
|
||||
"16:9",
|
||||
] as const;
|
||||
|
||||
function normalizeBaseURL(baseURL: string) {
|
||||
const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
|
||||
if (!normalized) return "https://api.bfl.ai";
|
||||
return /\/v1$/i.test(normalized) ? normalized.slice(0, -3) : normalized;
|
||||
}
|
||||
|
||||
function encodeModelPath(modelId: string) {
|
||||
return modelId
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
|
||||
const normalized = String(imageSize || "").trim().toUpperCase();
|
||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
|
||||
return "2K";
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(value?: string) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized || normalized.toLowerCase() === "auto") return "auto";
|
||||
if ((SUPPORTED_ASPECT_RATIOS as readonly string[]).includes(normalized)) return normalized;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseAspectRatioValue(ratio: string) {
|
||||
const [width, height] = ratio.split(":").map((item) => Number(item));
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return width / height;
|
||||
}
|
||||
|
||||
function roundToMultipleOf32(value: number) {
|
||||
return Math.max(32, Math.round(value / 32) * 32);
|
||||
}
|
||||
|
||||
function resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
||||
const baseMap: Record<typeof imageSize, number> = {
|
||||
"1K": 1024,
|
||||
"2K": 1536,
|
||||
"4K": 2048,
|
||||
};
|
||||
const base = baseMap[imageSize] || 1536;
|
||||
const ratioValue = parseAspectRatioValue(aspectRatio);
|
||||
|
||||
if (ratioValue >= 1) {
|
||||
return {
|
||||
width: base,
|
||||
height: roundToMultipleOf32(base / ratioValue),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: roundToMultipleOf32(base * ratioValue),
|
||||
height: base,
|
||||
};
|
||||
}
|
||||
|
||||
async function inferAspectRatioFromImageSource(imageUrl: string): Promise<string | null> {
|
||||
const normalized = String(imageUrl || "").trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
let buffer: Buffer | null = null;
|
||||
if (normalized.startsWith("data:")) {
|
||||
const match = normalized.match(/^data:([A-Za-z0-9.+/-]+);base64,(.+)$/);
|
||||
if (!match) return null;
|
||||
buffer = Buffer.from(match[2], "base64");
|
||||
} else if (/^https?:\/\//i.test(normalized)) {
|
||||
const response = await fetch(normalized, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
if (!buffer?.length) return null;
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
if (!metadata.width || !metadata.height || metadata.height <= 0) return null;
|
||||
const actualRatio = metadata.width / metadata.height;
|
||||
let closest = "1:1";
|
||||
let minDiff = Number.POSITIVE_INFINITY;
|
||||
for (const candidate of SUPPORTED_ASPECT_RATIOS) {
|
||||
const diff = Math.abs(parseAspectRatioValue(candidate) - actualRatio);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = candidate;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
} catch (error) {
|
||||
console.warn("[BFL Image] Failed to infer aspect ratio:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectImageExtension(imageUrl: string, contentType?: string | null) {
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
|
||||
if (type.includes("webp")) return "webp";
|
||||
if (type.includes("png")) return "png";
|
||||
|
||||
const cleanUrl = imageUrl.split("?")[0]?.toLowerCase() || "";
|
||||
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "jpg";
|
||||
if (cleanUrl.endsWith(".webp")) return "webp";
|
||||
if (cleanUrl.endsWith(".png")) return "png";
|
||||
return "png";
|
||||
}
|
||||
|
||||
function generateFilename(prefix: string, extension: string) {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `${prefix}-${timestamp}-${random}.${extension}`;
|
||||
}
|
||||
|
||||
async function createThumbnail(buffer: Buffer) {
|
||||
try {
|
||||
return await sharp(buffer)
|
||||
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
console.error("[BFL Image] Failed to create thumbnail:", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadGeneratedBuffer(
|
||||
buffer: Buffer,
|
||||
extension: string,
|
||||
): Promise<UploadedImage> {
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const originalFilename = generateFilename("bfl-image-orig", extension);
|
||||
const originalArrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
|
||||
|
||||
const thumbFilename = generateFilename("bfl-image-thumb", "jpg");
|
||||
const thumbArrayBuffer = thumbnailBuffer.buffer.slice(
|
||||
thumbnailBuffer.byteOffset,
|
||||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const thumbUploaded = await uploadFile(thumbFilename, thumbArrayBuffer);
|
||||
|
||||
return {
|
||||
thumbnail: thumbUploaded.url,
|
||||
original: originalUploaded.url,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage> {
|
||||
const response = await fetch(imageUrl, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download generated image: ${response.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const extension = detectImageExtension(imageUrl, response.headers.get("content-type"));
|
||||
return uploadGeneratedBuffer(buffer, extension);
|
||||
}
|
||||
|
||||
function extractPromptAndImages(messages: Array<{ role: string; content: any }>) {
|
||||
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
|
||||
if (!lastUserMessage) {
|
||||
throw new Error("No user message found");
|
||||
}
|
||||
|
||||
let prompt = "";
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
const pushImageUrl = (value?: string) => {
|
||||
if (!value) return;
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) return;
|
||||
if (!imageUrls.includes(normalized)) {
|
||||
imageUrls.push(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof lastUserMessage.content === "string") {
|
||||
prompt = lastUserMessage.content.trim();
|
||||
} else if (Array.isArray(lastUserMessage.content)) {
|
||||
for (const part of lastUserMessage.content) {
|
||||
if (part?.type === "text" && typeof part.text === "string") {
|
||||
prompt = `${prompt} ${part.text}`.trim();
|
||||
} else if (part?.type === "image_url" && part.image_url?.url) {
|
||||
pushImageUrl(part.image_url.url);
|
||||
} else if (part?.type === "file") {
|
||||
pushImageUrl(part.url || part.data);
|
||||
} else if (part?.type === "image") {
|
||||
pushImageUrl(part.image || part.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error("No prompt found in user message");
|
||||
}
|
||||
|
||||
return { prompt, imageUrls };
|
||||
}
|
||||
|
||||
function extractBflImageUrl(payload: any): string {
|
||||
const candidates = [
|
||||
payload?.sample,
|
||||
payload?.result?.sample,
|
||||
payload?.response?.result?.sample,
|
||||
payload?.output?.[0],
|
||||
payload?.result?.output?.[0],
|
||||
payload?.images?.[0],
|
||||
payload?.result?.images?.[0],
|
||||
payload?.image_url,
|
||||
payload?.result?.image_url,
|
||||
payload?.url,
|
||||
payload?.result?.url,
|
||||
];
|
||||
|
||||
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 extractErrorMessage(payload: any, fallback: string) {
|
||||
const message = String(
|
||||
payload?.error?.message ||
|
||||
payload?.error ||
|
||||
payload?.detail ||
|
||||
payload?.message ||
|
||||
payload?.response?.error?.message ||
|
||||
payload?.response?.error ||
|
||||
""
|
||||
).trim();
|
||||
return message || fallback;
|
||||
}
|
||||
|
||||
async function submitBflImageRequest(params: {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
seed?: number;
|
||||
}) {
|
||||
const endpoint = `${normalizeBaseURL(params.baseURL)}/v1/${encodeModelPath(params.model)}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-key": params.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
input_image: params.imageUrl,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
output_format: "jpeg",
|
||||
safety_tolerance: 2,
|
||||
...(typeof params.seed === "number" ? { seed: params.seed } : {}),
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as BflSubmitResponse | null;
|
||||
const imageUrl = extractBflImageUrl(payload);
|
||||
const taskId = String(payload?.id || payload?.response?.id || "").trim();
|
||||
const pollingUrl = String(payload?.polling_url || payload?.response?.polling_url || "").trim();
|
||||
|
||||
if (response.ok && (imageUrl || taskId || pollingUrl)) {
|
||||
return {
|
||||
imageUrl,
|
||||
taskId,
|
||||
pollingUrl,
|
||||
errorMessage: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
taskId,
|
||||
pollingUrl,
|
||||
errorMessage: extractErrorMessage(payload, `BFL 请求失败(${response.status})`),
|
||||
};
|
||||
}
|
||||
|
||||
async function pollBflResult(params: {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
pollingUrl?: string;
|
||||
taskId?: string;
|
||||
}) {
|
||||
const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const pollingUrl =
|
||||
String(params.pollingUrl || "").trim() ||
|
||||
`${normalizeBaseURL(params.baseURL)}/v1/get_result?id=${encodeURIComponent(String(params.taskId || ""))}`;
|
||||
|
||||
const response = await fetch(pollingUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-key": params.apiKey,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as BflSubmitResponse | null;
|
||||
const imageUrl = extractBflImageUrl(payload);
|
||||
if (imageUrl) return imageUrl;
|
||||
|
||||
const status = String(
|
||||
payload?.status ||
|
||||
payload?.task_status ||
|
||||
payload?.response?.status ||
|
||||
payload?.response?.task_status ||
|
||||
""
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (status === "failed" || status === "error" || status === "cancelled") {
|
||||
throw new Error(extractErrorMessage(payload, "BFL 任务执行失败"));
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
|
||||
throw new Error("BFL 轮询超时");
|
||||
}
|
||||
|
||||
function formatMarkdown(image: UploadedImage, imageSize: string) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
export function createBflImage({ apiKey, baseURL }: BflImageConfig) {
|
||||
return {
|
||||
provider: "bfl-image",
|
||||
chat: (modelId: string) => ({
|
||||
provider: "bfl-image",
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
imageParams?: BflImageParams;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||
if (imageUrls.length === 0) {
|
||||
throw new Error("BFL 图片编辑模型需要至少上传一张图片。");
|
||||
}
|
||||
|
||||
const activeModelId = options.overrideModelId || modelId;
|
||||
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
|
||||
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
|
||||
const aspectRatio =
|
||||
normalizedAspectRatio === "auto"
|
||||
? await inferAspectRatioFromImageSource(imageUrls[0] || "")
|
||||
: normalizedAspectRatio;
|
||||
const { width, height } = resolveBflDimensions(aspectRatio || "1:1", imageSize);
|
||||
|
||||
const submitResult = await submitBflImageRequest({
|
||||
baseURL,
|
||||
apiKey,
|
||||
model: activeModelId,
|
||||
prompt,
|
||||
imageUrl: imageUrls[0],
|
||||
width,
|
||||
height,
|
||||
seed: options.imageParams?.seed,
|
||||
});
|
||||
|
||||
if (submitResult.errorMessage) {
|
||||
throw new Error(submitResult.errorMessage);
|
||||
}
|
||||
|
||||
const finalImageUrl =
|
||||
submitResult.imageUrl ||
|
||||
(await pollBflResult({
|
||||
baseURL,
|
||||
apiKey,
|
||||
pollingUrl: submitResult.pollingUrl,
|
||||
taskId: submitResult.taskId,
|
||||
}));
|
||||
|
||||
if (!finalImageUrl) {
|
||||
throw new Error("BFL 未返回可用图片");
|
||||
}
|
||||
|
||||
const uploaded = await downloadAndUploadImage(finalImageUrl);
|
||||
const text = formatMarkdown(uploaded, imageSize);
|
||||
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: text.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content: any }>;
|
||||
imageParams?: BflImageParams;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
yield {
|
||||
type: "text-delta",
|
||||
textDelta: result.text,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user