import "server-only"; import sharp from "sharp"; import { getBananaImageModelPresetById, resolveBananaImageRuntimeModelId, } from "@/lib/ai/bananaimage-models"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { uploadFile } from "@/lib/storage/service"; interface BananaImageConfig { apiKey: string; baseURL: string; } interface BananaImageParams { aspectRatio?: string; imageSize?: string; imageCount?: number; } interface BananaImageModelConfig { apiFormat?: "bananaimage-gemini" | "bananaimage-gpt-image-2"; maxReferenceImages?: number; supportedAspectRatios?: string[]; supportedImageSizes?: string[]; } interface ReferenceImage { base64: string; dataUrl: string; mimeType: string; width?: number; height?: number; } interface UploadedImage { thumbnail: string; original: string; } const GEMINI_POLL_INTERVAL_MS = 3000; const GEMINI_POLL_MAX_ATTEMPTS = 600; const GPT_POLL_INTERVAL_MS = 4000; const GPT_POLL_MAX_ATTEMPTS = 90; const MAX_TRANSIENT_POLL_FAILURES = 8; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; function joinApiPath(baseURL: string, path: string) { const cleanBase = baseURL.replace(/\/+$/, ""); const cleanPath = path.startsWith("/") ? path : `/${path}`; return `${cleanBase}${cleanPath}`; } function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function normalizeModelId(modelId?: string) { const normalized = String(modelId || "").trim(); if (!normalized) throw new Error("BananaImage 模型 ID 未配置"); return normalized; } function normalizeAspectRatioLabel(value?: string) { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) return "auto"; if (normalized === "auto") return "auto"; const match = normalized.match(/^(\d+)\s*:\s*(\d+)$/); if (!match) return "auto"; return `${Number(match[1])}:${Number(match[2])}`; } function parseAspectRatio(value: string) { const match = value.match(/^(\d+):(\d+)$/); if (!match) return null; const width = Number(match[1]); const height = Number(match[2]); if (!width || !height) return null; return width / height; } function findClosestAspectRatio( width: number, height: number, supportedAspectRatios: string[] ) { const target = width / height; let closest = supportedAspectRatios.includes("1:1") ? "1:1" : supportedAspectRatios[0] || "1:1"; let score = Number.POSITIVE_INFINITY; for (const ratio of supportedAspectRatios) { const value = parseAspectRatio(ratio); if (!value) continue; const candidateScore = Math.abs(Math.log(target / value)); if (candidateScore < score) { closest = ratio; score = candidateScore; } } return closest; } function getSupportedAspectRatios( modelId: string, modelConfig?: BananaImageModelConfig | null ) { const configured = modelConfig?.supportedAspectRatios; const preset = getBananaImageModelPresetById(modelId); const ratios = Array.isArray(configured) ? configured : (preset?.imageGenConfig?.supportedAspectRatios as string[] | undefined); const normalized = (ratios || []) .map((ratio) => normalizeAspectRatioLabel(ratio)) .filter((ratio) => ratio !== "auto" && parseAspectRatio(ratio) !== null); return Array.from(new Set(normalized.length > 0 ? normalized : ["1:1"])); } function resolveAspectRatio({ requested, referenceImages, supportedAspectRatios, }: { requested?: string; referenceImages: ReferenceImage[]; supportedAspectRatios: string[]; }) { const normalized = normalizeAspectRatioLabel(requested); if (normalized !== "auto" && supportedAspectRatios.includes(normalized)) { return normalized; } const firstReference = referenceImages[0]; if (firstReference?.width && firstReference?.height) { return findClosestAspectRatio( firstReference.width, firstReference.height, supportedAspectRatios ); } return supportedAspectRatios.includes("1:1") ? "1:1" : supportedAspectRatios[0] || "1:1"; } function normalizeImageSize( requested?: string, modelConfig?: BananaImageModelConfig | null ) { const supported = (modelConfig?.supportedImageSizes || ["1K", "2K", "4K"]) .map((size) => String(size).trim().toUpperCase()) .filter((size) => ["1K", "2K", "4K"].includes(size)); const normalized = String(requested || "2K").trim().toUpperCase(); if (supported.includes(normalized)) return normalized; return supported.includes("2K") ? "2K" : supported[0] || "2K"; } function resolveApiFormat( modelId: string, modelConfig?: BananaImageModelConfig | null ) { if (modelConfig?.apiFormat) return modelConfig.apiFormat; const preset = getBananaImageModelPresetById(modelId); const configured = preset?.imageGenConfig?.apiFormat; if ( configured === "bananaimage-gemini" || configured === "bananaimage-gpt-image-2" ) { return configured; } return modelId === "gpt-image-2" ? "bananaimage-gpt-image-2" : "bananaimage-gemini"; } function extractPromptAndImages( messages: Array<{ role: string; content?: any; parts?: any[] }> ) { const lastUserMessage = messages.filter((message) => message.role === "user").pop(); if (!lastUserMessage) throw new Error("未找到用户消息"); const texts: string[] = []; const imageUrls: string[] = []; const seenImages = new Set(); const pushText = (value: unknown) => { if (typeof value === "string" && value.trim()) texts.push(value.trim()); }; const pushImage = (value: unknown) => { let url = ""; if (typeof value === "string") url = value; else if (value instanceof URL) url = value.toString(); else if (value && typeof value === "object") { url = String( (value as any).url || (value as any).imageUrl || (value as any).fileUrl || (value as any).src || "" ); } url = url.trim(); if (!url || seenImages.has(url)) return; if (!url.startsWith("data:image/") && !/^https?:\/\//i.test(url)) return; seenImages.add(url); imageUrls.push(url); }; const visitPart = (part: any) => { if (!part) return; if (typeof part === "string") { pushText(part); return; } if (part.type === "text" || typeof part.text === "string") { pushText(part.text); } if (part.type === "image_url") pushImage(part.image_url); if (part.type === "image") pushImage(part.image || part.url); if (part.type === "file") pushImage(part.url || part.file || part.data); if (part.inlineData?.data) { pushImage( `data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}` ); } }; const content = lastUserMessage.content; if (typeof content === "string") pushText(content); else if (Array.isArray(content)) content.forEach(visitPart); if (Array.isArray(lastUserMessage.parts)) lastUserMessage.parts.forEach(visitPart); const prompt = texts.join("\n").trim(); if (!prompt) throw new Error("请输入图片生成或编辑要求"); return { prompt, imageUrls }; } function parseDataUrl(dataUrl: string) { const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/s); if (!match) throw new Error("参考图 Base64 格式无效"); const mimeType = match[1] || "image/png"; const base64 = match[2] || ""; return { mimeType, base64, buffer: Buffer.from(base64, "base64") }; } async function loadReferenceImage(imageUrl: string): Promise { let mimeType = "image/png"; let base64 = ""; let buffer: Buffer; if (imageUrl.startsWith("data:")) { const parsed = parseDataUrl(imageUrl); mimeType = parsed.mimeType; base64 = parsed.base64; buffer = parsed.buffer; } else { const response = await fetch(imageUrl, { cache: "no-store", redirect: "follow", signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS), headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8", "User-Agent": "yedir-ai-bananaimage/1.0", }, }); if (!response.ok) { throw new Error(`BananaImage 参考图下载失败(HTTP ${response.status})`); } mimeType = response.headers.get("content-type")?.split(";")[0] || "image/png"; buffer = Buffer.from(await response.arrayBuffer()); base64 = buffer.toString("base64"); } const metadata = await sharp(buffer, { failOn: "none" }).metadata().catch(() => null); return { base64, dataUrl: `data:${mimeType};base64,${base64}`, mimeType, width: metadata?.width, height: metadata?.height, }; } function detectImageExtension(mimeType?: string | null) { const normalized = String(mimeType || "").toLowerCase(); if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg"; if (normalized.includes("webp")) return "webp"; if (normalized.includes("gif")) return "gif"; return "png"; } function generateFilename(prefix: string, extension: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`; } function toArrayBuffer(buffer: Buffer) { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } async function uploadImageBuffer( buffer: Buffer, mimeType?: string | null ): Promise { const extension = detectImageExtension(mimeType); const thumbnailBuffer = await sharp(buffer, { failOn: "none" }) .resize(400, 400, { fit: "inside", withoutEnlargement: true }) .jpeg({ quality: 82 }) .toBuffer() .catch(() => buffer); const original = await uploadFile( generateFilename("bananaimage-orig", extension), toArrayBuffer(buffer) ); const thumbnail = await uploadFile( generateFilename("bananaimage-thumb", "jpg"), toArrayBuffer(thumbnailBuffer) ); return { original: original.url, thumbnail: thumbnail.url }; } async function archiveOutputImage(imageUrl: string): Promise { if (imageUrl.startsWith("data:")) { const parsed = parseDataUrl(imageUrl); return uploadImageBuffer(parsed.buffer, parsed.mimeType); } const deferred = await createDeferredUploadedImage(imageUrl, { providerType: "bananaimage", }); if (deferred) return deferred; const response = await fetch(imageUrl, { cache: "no-store", redirect: "follow", signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS), }); if (!response.ok) { throw new Error(`BananaImage 结果图片下载失败(HTTP ${response.status})`); } return uploadImageBuffer( Buffer.from(await response.arrayBuffer()), response.headers.get("content-type") ); } async function parseJsonResponse(response: Response) { const rawText = await response.text(); if (!rawText.trim()) return null; try { return JSON.parse(rawText); } catch { return rawText; } } function extractErrorMessage(payload: any, fallback = "BananaImage 请求失败") { if (typeof payload === "string" && payload.trim()) return payload.trim(); return ( payload?.error?.message || payload?.error || payload?.message || payload?.detail || fallback ); } function extractTaskId(payload: any) { const value = payload?.task_id || payload?.taskId || payload?.id || payload?.data?.task_id || payload?.data?.taskId || payload?.data?.id; return typeof value === "string" && value.trim() ? value.trim() : ""; } function extractGeminiImages(payload: any) { const parts = payload?.candidates?.[0]?.content?.parts; if (!Array.isArray(parts)) return []; const images: string[] = []; for (const part of parts) { if (part?.inlineData?.data) { images.push( `data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}` ); } else if (part?.fileData?.fileUri) { images.push(String(part.fileData.fileUri)); } else if (/^https?:\/\/\S+$/i.test(String(part?.text || "").trim())) { images.push(String(part.text).trim()); } } return Array.from(new Set(images)); } async function submitGeminiTask({ baseURL, apiKey, model, prompt, referenceImages, aspectRatio, imageSize, }: { baseURL: string; apiKey: string; model: string; prompt: string; referenceImages: ReferenceImage[]; aspectRatio: string; imageSize: string; }) { const response = await fetch( joinApiPath(baseURL, "/api/gemini/v1beta/asyncGenerateContent"), { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "X-Client-Source": "web", }, body: JSON.stringify({ contents: [ { role: "user", parts: [ { text: prompt }, ...referenceImages.map((image) => ({ inlineData: { mimeType: image.mimeType, data: image.base64 }, })), ], }, ], generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio, imageSize }, }, enableFallback: true, model, }), } ); const payload = await parseJsonResponse(response); if (!response.ok) { throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`); } const taskId = extractTaskId(payload); if (!taskId) throw new Error("BananaImage 未返回 task_id"); return taskId; } async function waitForGeminiTask(baseURL: string, apiKey: string, taskId: string) { let transientFailures = 0; for (let attempt = 0; attempt < GEMINI_POLL_MAX_ATTEMPTS; attempt += 1) { await delay(GEMINI_POLL_INTERVAL_MS); try { const response = await fetch( joinApiPath(baseURL, `/api/gemini/v1beta/tasks/${encodeURIComponent(taskId)}`), { headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" } ); if (!response.ok) { if (response.status >= 500) throw new Error(`HTTP ${response.status}`); const payload = await parseJsonResponse(response); throw new Error(extractErrorMessage(payload)); } const payload = await parseJsonResponse(response); transientFailures = 0; if (payload?.status === "completed") { const images = extractGeminiImages(payload.response); if (images.length === 0) throw new Error("BananaImage 任务完成但未返回图片"); return images; } if (payload?.status === "failed") { throw new Error(extractErrorMessage(payload, "BananaImage 图片生成失败")); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!/^HTTP 5\d\d$/.test(message) && !/fetch|network|abort/i.test(message)) { throw error; } transientFailures += 1; if (transientFailures >= MAX_TRANSIENT_POLL_FAILURES) throw error; } } throw new Error("BananaImage 生成超时,任务可能仍在处理,请稍后重试"); } async function submitGptImageTask({ baseURL, apiKey, prompt, referenceImages, aspectRatio, imageSize, }: { baseURL: string; apiKey: string; prompt: string; referenceImages: ReferenceImage[]; aspectRatio: string; imageSize: string; }) { const body: Record = { model: "gpt-image-2", prompt, size: aspectRatio.toLowerCase(), resolution: imageSize.toLowerCase(), }; if (referenceImages.length > 0) { body.image_urls = referenceImages.map((image) => image.dataUrl); } const response = await fetch(joinApiPath(baseURL, "/api/v1/images/generations"), { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "X-Client-Source": "web", }, body: JSON.stringify(body), }); const payload = await parseJsonResponse(response); if (!response.ok) { throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`); } const taskId = extractTaskId(payload); if (!taskId) throw new Error("BananaImage GPT Image 2 未返回 task_id"); return taskId; } async function waitForGptImageTask(baseURL: string, apiKey: string, taskId: string) { let transientFailures = 0; for (let attempt = 0; attempt < GPT_POLL_MAX_ATTEMPTS; attempt += 1) { await delay(GPT_POLL_INTERVAL_MS); try { const response = await fetch( joinApiPath(baseURL, `/api/v1/images/tasks/${encodeURIComponent(taskId)}`), { headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" } ); if (!response.ok) { if (response.status >= 500) throw new Error(`HTTP ${response.status}`); const payload = await parseJsonResponse(response); throw new Error(extractErrorMessage(payload)); } const payload = await parseJsonResponse(response); transientFailures = 0; if (payload?.status === "completed") { const url = String(payload?.url || "").trim(); if (!url) throw new Error("BananaImage GPT Image 2 任务完成但未返回图片"); return [url]; } if (payload?.status === "failed") { throw new Error(extractErrorMessage(payload, "BananaImage GPT Image 2 生成失败")); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!/^HTTP 5\d\d$/.test(message) && !/fetch|network|abort/i.test(message)) { throw error; } transientFailures += 1; if (transientFailures >= MAX_TRANSIENT_POLL_FAILURES) throw error; } } throw new Error("BananaImage GPT Image 2 生成超时,请稍后重试"); } function formatMarkdown(images: UploadedImage[], imageSize: string) { return images .map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`) .join("\n\n"); } export function createBananaImage({ apiKey, baseURL }: BananaImageConfig) { return { provider: "bananaimage", chat: (modelId: string) => ({ provider: "bananaimage", doGenerate: async (options: { messages: Array<{ role: string; content?: any; parts?: any[] }>; imageParams?: BananaImageParams; imageGenConfig?: BananaImageModelConfig | null; overrideModelId?: string; }) => { const requestedModelId = normalizeModelId(options.overrideModelId || modelId); const runtimeModelId = resolveBananaImageRuntimeModelId(requestedModelId); const apiFormat = resolveApiFormat(requestedModelId, options.imageGenConfig); const { prompt, imageUrls } = extractPromptAndImages(options.messages); const maxReferenceImages = Math.max( 0, Math.min(14, options.imageGenConfig?.maxReferenceImages || 14) ); const referenceImages = await Promise.all( imageUrls.slice(0, maxReferenceImages).map(loadReferenceImage) ); const supportedAspectRatios = getSupportedAspectRatios( requestedModelId, options.imageGenConfig ); const aspectRatio = resolveAspectRatio({ requested: options.imageParams?.aspectRatio, referenceImages, supportedAspectRatios, }); const imageSize = normalizeImageSize( options.imageParams?.imageSize, options.imageGenConfig ); let outputUrls: string[]; if (apiFormat === "bananaimage-gpt-image-2") { const taskId = await submitGptImageTask({ baseURL, apiKey, prompt, referenceImages, aspectRatio, imageSize, }); outputUrls = await waitForGptImageTask(baseURL, apiKey, taskId); } else { const taskId = await submitGeminiTask({ baseURL, apiKey, model: runtimeModelId, prompt, referenceImages, aspectRatio, imageSize, }); outputUrls = await waitForGeminiTask(baseURL, apiKey, taskId); } const uploadedImages = await Promise.all( outputUrls.slice(0, Math.max(1, options.imageParams?.imageCount || 1)).map(archiveOutputImage) ); const text = formatMarkdown(uploadedImages, imageSize); return { text, finishReason: "stop" as const, usage: { promptTokens: 0, completionTokens: text.length }, }; }, doStream: async function* (options: { messages: Array<{ role: string; content?: any; parts?: any[] }>; imageParams?: BananaImageParams; imageGenConfig?: BananaImageModelConfig | null; overrideModelId?: string; }) { const result = await this.doGenerate(options); yield { type: "text-delta", textDelta: result.text }; }, }), }; }