diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 7294bf264..6b12b6148 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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 = { "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 = { "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 = { "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" diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts index 016cdda72..0dbbce36f 100644 --- a/lib/ai/dynamic-provider.ts +++ b/lib/ai/dynamic-provider.ts @@ -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 = { // 图片编辑:优先 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", }, }; diff --git a/lib/ai/providers/bfl-image.ts b/lib/ai/providers/bfl-image.ts new file mode 100644 index 000000000..4216c5c74 --- /dev/null +++ b/lib/ai/providers/bfl-image.ts @@ -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 = { + "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 { + 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 { + 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 { + 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 `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`; +} + +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, + }; + }, + }), + }; +}