import "server-only"; import sharp from "sharp"; import { uploadFile } from "@/lib/storage/service"; type 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 = 180_000; const TRAILING_SLASHES_REGEX = /\/+$/; const TRAILING_V1_REGEX = /\/v1$/i; const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i; const BFL_MODEL_PREFIX_REGEX = /^bfl\//i; const KONTEXT_MODEL_REGEX = /kontext/i; 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(TRAILING_SLASHES_REGEX, ""); if (!normalized) { return "https://api.bfl.ai"; } return TRAILING_V1_REGEX.test(normalized) ? normalized.slice(0, -3) : normalized; } function isBltcyBaseURL(baseURL: string) { try { const parsed = new URL(String(baseURL || "").trim() || "https://api.bfl.ai"); return BLTCY_HOST_REGEX.test(parsed.hostname); } catch { return false; } } function normalizeBltcyBaseURL(baseURL: string) { const fallback = "https://api.bltcy.ai"; try { const parsed = new URL(String(baseURL || "").trim() || fallback); return `${parsed.protocol}//${parsed.host}`; } catch { return fallback; } } function encodeModelPath(modelId: string) { return modelId .split("/") .filter(Boolean) .map((part) => encodeURIComponent(part)) .join("/"); } function normalizeBflModelPath(modelId: string) { return String(modelId || "").trim().replace(BFL_MODEL_PREFIX_REGEX, ""); } function createAuthHeaders(baseURL: string, apiKey: string): Record { if (isBltcyBaseURL(baseURL)) { return { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json", }; } return { "x-key": apiKey, "Content-Type": "application/json", Accept: "application/json", }; } function createSubmitEndpoint(baseURL: string, model: string) { const modelPath = encodeModelPath(normalizeBflModelPath(model)); if (isBltcyBaseURL(baseURL)) { return `${normalizeBltcyBaseURL(baseURL)}/bfl/v1/${modelPath}`; } return `${normalizeBaseURL(baseURL)}/v1/${modelPath}`; } function createPollingEndpoint(baseURL: string, pollingUrl?: string, taskId?: string) { const normalizedTaskId = String(taskId || "").trim(); if (isBltcyBaseURL(baseURL) && normalizedTaskId) { return `${normalizeBltcyBaseURL(baseURL)}/bfl/v1/get_result?id=${encodeURIComponent( normalizedTaskId )}`; } const normalizedPollingUrl = String(pollingUrl || "").trim(); if (normalizedPollingUrl) { return normalizedPollingUrl; } return `${normalizeBaseURL(baseURL)}/v1/get_result?id=${encodeURIComponent( normalizedTaskId )}`; } 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 = createSubmitEndpoint(params.baseURL, params.model); const normalizedModel = normalizeBflModelPath(params.model); const isKontextModel = KONTEXT_MODEL_REGEX.test(normalizedModel); const requestBody = { prompt: params.prompt, ...(params.imageUrl ? { input_image: params.imageUrl } : {}), ...(!isKontextModel ? { width: params.width, height: params.height } : {}), output_format: "jpeg", safety_tolerance: 2, ...(typeof params.seed === "number" ? { seed: params.seed } : {}), }; const response = await fetch(endpoint, { method: "POST", headers: createAuthHeaders(params.baseURL, params.apiKey), body: JSON.stringify(requestBody), 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 = createPollingEndpoint( params.baseURL, params.pollingUrl, params.taskId ); const response = await fetch(pollingUrl, { method: "GET", headers: createAuthHeaders(params.baseURL, params.apiKey), 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); 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, }; }, }), }; }