ai-chatbot/lib/ai/providers/bfl-image.ts

543 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import sharp from "sharp";
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
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 = 600_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<string, string> {
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 resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
const baseMap: Record<typeof imageSize, number> = {
"1K": 1024,
"2K": 1536,
"4K": 2048,
};
return resolveLongestEdgeDimensions({
aspectRatio,
longestEdge: baseMap[imageSize] || 1536,
constraints: {
maxEdge: 4096,
maxPixels: 4096 * 4096,
minEdge: 32,
multiple: 32,
},
});
}
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 deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "bfl-image",
});
if (deferred) return deferred;
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,
};
},
}),
};
}