diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 58030389f..98cc368c4 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -283,6 +283,61 @@ interface GeminiResponse { }>; } +function joinOpenAICompatibleBaseUrl(baseURL: string, path: string) { + const normalized = String(baseURL || "").trim().replace(/\/+$/, ""); + const withoutVersion = normalized.replace(/\/v1$/i, ""); + return `${withoutVersion}${path}`; +} + +async function loadImageAsBlob(imageUrl: string): Promise<{ blob: Blob; filename: string }> { + const normalized = String(imageUrl || "").trim(); + if (!normalized) { + throw new Error("参考图地址为空"); + } + + if (normalized.startsWith("data:")) { + const match = normalized.match(/^data:([A-Za-z0-9.+/-]+);base64,(.+)$/); + if (!match) { + throw new Error("参考图 Base64 格式无效"); + } + const mimeType = match[1] || "image/png"; + const ext = mimeType.split("/")[1] || "png"; + return { + blob: new Blob([Buffer.from(match[2], "base64")], { type: mimeType }), + filename: `reference.${ext}`, + }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + try { + const response = await fetch(normalized, { + signal: controller.signal, + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentType = response.headers.get("content-type") || "image/png"; + const extFromMime = contentType.split("/")[1]?.split(";")[0] || "png"; + const extFromUrl = normalized + .split("?")[0] + .match(/\.([A-Za-z0-9]+)$/)?.[1]; + const ext = extFromUrl || extFromMime; + + return { + blob: new Blob([await response.arrayBuffer()], { type: contentType }), + filename: `reference.${ext}`, + }; + } finally { + clearTimeout(timeoutId); + } +} + /** * 从文本中提取图片数据 * 返回 { url, isBase64, mimeType, base64Data } 数组 @@ -319,7 +374,35 @@ async function extractAndUploadImages(response: any): Promise = []; const uploadPromises: Promise[] = []; - // 1. 尝试 OpenAI 格式 (choices[0].message.content) + // 1. 尝试 OpenAI Images API 格式 (data[].b64_json / data[].url) + if (Array.isArray(response.data)) { + logger.debug("[Gemini Image] Parsing OpenAI Images format, data count:", response.data.length); + for (const item of response.data) { + if (item?.b64_json && typeof item.b64_json === "string") { + const base64Data = item.b64_json.startsWith("data:") + ? item.b64_json.split(",", 2)[1] || item.b64_json + : item.b64_json; + const uploadPromise = uploadBase64ToS3(base64Data, "image/png") + .then((urls) => { + images.push(urls); + }); + uploadPromises.push(uploadPromise); + } else if (item?.url && typeof item.url === "string") { + logger.debug("[Gemini Image] Found OpenAI Images URL, will download and upload to S3:", item.url.substring(0, 100) + "..."); + const uploadPromise = downloadAndUploadToS3(item.url) + .then((urls) => { + images.push(urls); + }) + .catch((error) => { + logger.error("[Gemini Image] Failed to download/upload OpenAI Images URL after retries:", error); + throw new Error(`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`); + }); + uploadPromises.push(uploadPromise); + } + } + } + + // 2. 尝试 OpenAI Chat 格式 (choices[0].message.content) if (response.choices && response.choices.length > 0) { const content = response.choices[0]?.message?.content; if (content && typeof content === "string") { @@ -351,7 +434,7 @@ async function extractAndUploadImages(response: any): Promise 0) { const candidate = response.candidates[0]; if (candidate.content && candidate.content.parts) { @@ -462,6 +545,107 @@ function normalizeGrsaiPayload(payload: any): any { return payload; } +async function sendGrsaiGptImageRequest(params: { + baseURL: string; + apiKey: string; + modelId: string; + prompt: string; + size: string; + imageUrls: string[]; + timeout: number; + apiLogPrefix: string; +}): Promise { + const { baseURL, apiKey, modelId, prompt, size, imageUrls, timeout, apiLogPrefix } = params; + const hasReferenceImage = imageUrls.length > 0; + const endpoint = joinOpenAICompatibleBaseUrl( + baseURL, + hasReferenceImage ? "/v1/images/edits" : "/v1/images/generations" + ); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + let response: Response; + + if (hasReferenceImage) { + const formData = new FormData(); + formData.append("model", modelId); + formData.append("prompt", prompt); + formData.append("size", size); + + const { blob, filename } = await loadImageAsBlob(imageUrls[0]); + formData.append("image", blob, filename); + + logger.debug( + `${apiLogPrefix} Sending OpenAI-compatible edit request: endpoint=${endpoint}, size=${size}, referenceImageCount=${imageUrls.length}` + ); + response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + body: formData, + signal: controller.signal, + }); + } else { + const requestBody = { + model: modelId, + prompt, + size, + }; + + logger.debug( + `${apiLogPrefix} Sending OpenAI-compatible generation request: endpoint=${endpoint}, size=${size}` + ); + response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + } + + clearTimeout(timeoutId); + + const responseText = await response.text(); + let data: any = null; + try { + data = responseText ? JSON.parse(responseText) : {}; + } catch { + data = { raw: responseText }; + } + + if (!response.ok) { + logger.error( + `${apiLogPrefix} OpenAI-compatible request failed:`, + response.status, + responseText.substring(0, 1000) + ); + const errorMessage = + data?.error?.message || + data?.message || + data?.error || + responseText || + `${response.status} ${response.statusText}`; + throw new Error(`图片生成失败: ${errorMessage}`); + } + + return data; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + const timeoutSeconds = Math.round(timeout / 1000); + logger.error(`${apiLogPrefix} Request timeout after ${timeoutSeconds} seconds`); + throw new Error(`Grsai GPT Image 请求超时(${timeoutSeconds}秒),将自动切换备用模型`); + } + throw error; + } +} + function getGrsaiTaskId(payload: any): string | null { const normalized = normalizeGrsaiPayload(payload); const candidates = [ @@ -818,6 +1002,19 @@ async function sendGeminiRequest( logger.debug( `${apiLogPrefix} Resolved size: aspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"} => ${resolvedSize}` ); + + const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; + logger.debug(`${apiLogPrefix} Timeout set to ${timeout / 1000} seconds for ${imageParams?.imageSize || "default"}`); + return sendGrsaiGptImageRequest({ + baseURL, + apiKey, + modelId, + prompt, + size: resolvedSize, + imageUrls: processedUrls, + timeout, + apiLogPrefix, + }); } else { // 添加 Nano Banana 特定参数 if (processedUrls.length > 0) {