From 14dbe3efeb4ad8261a37342bc5620e11557f0d91 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 5 Jun 2026 11:52:57 +0800 Subject: [PATCH] fix: poll grsai nano banana tasks asynchronously --- lib/ai/providers/gemini-image.ts | 267 +++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index d8200b528..04f021239 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -447,6 +447,259 @@ function formatImageResponse( return images.map((img) => `![thumb|||${img.original}|||${imageSize}](${img.thumbnail})`).join("\n\n"); } +const GRSAI_NANO_BANANA_SUBMIT_TIMEOUT_MS = 30000; +const GRSAI_NANO_BANANA_POLL_TIMEOUT_MS = 30000; +const GRSAI_NANO_BANANA_POLL_INTERVAL_MS = 5000; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeGrsaiPayload(payload: any): any { + if (isRecord(payload?.data)) { + return payload.data; + } + return payload; +} + +function getGrsaiTaskId(payload: any): string | null { + const normalized = normalizeGrsaiPayload(payload); + const candidates = [ + normalized?.id, + normalized?.taskId, + normalized?.task_id, + normalized?.task?.id, + normalized?.data?.id, + payload?.id, + payload?.taskId, + payload?.task_id, + ]; + + for (const candidate of candidates) { + const value = String(candidate || "").trim(); + if (value) return value; + } + + return null; +} + +function getGrsaiStatus(payload: any): string { + const normalized = normalizeGrsaiPayload(payload); + return String( + normalized?.status || + normalized?.state || + normalized?.taskStatus || + payload?.status || + "" + ).toLowerCase(); +} + +function getGrsaiProgress(payload: any): number | null { + const normalized = normalizeGrsaiPayload(payload); + const value = + normalized?.progress ?? + normalized?.percentage ?? + normalized?.percent ?? + payload?.progress; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function getGrsaiErrorMessage(payload: any): string { + const normalized = normalizeGrsaiPayload(payload); + const messages = [ + normalized?.failure_reason, + normalized?.error, + normalized?.message, + payload?.msg, + payload?.message, + payload?.error, + ] + .map((part) => String(part || "").trim()) + .filter(Boolean); + + return messages.join(" / ") || "未知错误"; +} + +function hasGrsaiImages(payload: any): boolean { + const normalized = normalizeGrsaiPayload(payload); + return ( + Array.isArray(normalized?.results) || + Array.isArray(normalized?.images) || + Boolean(normalized?.url || normalized?.imageUrl || normalized?.image_url) + ); +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +} + +async function readJsonResponse(response: Response, label: string): Promise { + const text = await response.text(); + + if (!response.ok) { + logger.error(`${label} failed:`, response.status, text.substring(0, 500)); + throw new Error(`${label} 接口错误:${response.status} ${response.statusText}`); + } + + try { + return JSON.parse(text); + } catch (error) { + logger.error(`${label} returned non-JSON response:`, text.substring(0, 500)); + throw new Error(`${label} 返回格式异常,未能解析任务结果`); + } +} + +async function pollGrsaiNanoBananaResult(params: { + baseURL: string; + apiKey: string; + taskId: string; + timeoutMs: number; + apiLogPrefix: string; +}): Promise { + const resultUrl = `${params.baseURL.replace(/\/+$/, "")}/v1/draw/result`; + const deadline = Date.now() + params.timeoutMs; + let lastPayload: any = null; + let lastProgress: number | null = null; + + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, GRSAI_NANO_BANANA_POLL_INTERVAL_MS)); + + const response = await fetchWithTimeout( + resultUrl, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ id: params.taskId }), + }, + GRSAI_NANO_BANANA_POLL_TIMEOUT_MS + ); + const payload = await readJsonResponse(response, `${params.apiLogPrefix} result`); + lastPayload = payload; + + const normalized = normalizeGrsaiPayload(payload); + const status = getGrsaiStatus(payload); + const progress = getGrsaiProgress(payload); + if (progress !== null && progress !== lastProgress) { + lastProgress = progress; + logger.debug(`${params.apiLogPrefix} Async task progress: ${progress}%`); + } + + if ( + status === "succeeded" || + status === "success" || + status === "completed" || + (progress !== null && progress >= 100 && hasGrsaiImages(payload)) + ) { + logger.debug(`${params.apiLogPrefix} Async task succeeded:`, { + taskId: params.taskId, + progress, + }); + return normalized; + } + + if (status === "failed" || status === "error") { + throw new Error(`图片生成失败: ${getGrsaiErrorMessage(payload)}`); + } + } + + logger.error(`${params.apiLogPrefix} Async task timeout:`, { + taskId: params.taskId, + lastPayload: JSON.stringify(lastPayload || {}).substring(0, 500), + }); + throw new Error( + `GRSAI Nano Banana 异步任务超时(${Math.round(params.timeoutMs / 1000)}秒),请稍后重试或切换备用模型` + ); +} + +async function submitAndPollGrsaiNanoBanana(params: { + baseURL: string; + apiKey: string; + requestBody: Record; + imageParams?: { + aspectRatio?: string; + imageSize?: string; + }; + apiLogPrefix: string; +}): Promise { + const submitUrl = `${params.baseURL.replace(/\/+$/, "")}/v1/draw/nano-banana`; + const body: Record = { + ...params.requestBody, + webHook: "-1", + }; + const imageSize = String(params.imageParams?.imageSize || "").toUpperCase(); + const pollTimeoutMs = imageSize === "4K" ? 600000 : 300000; + + logger.debug(`${params.apiLogPrefix} Submitting async Nano Banana task:`, { + model: body.model, + imageSize: params.imageParams?.imageSize, + aspectRatio: params.imageParams?.aspectRatio, + }); + + const response = await fetchWithTimeout( + submitUrl, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }, + GRSAI_NANO_BANANA_SUBMIT_TIMEOUT_MS + ); + const submitPayload = await readJsonResponse(response, `${params.apiLogPrefix} submit`); + const submitStatus = getGrsaiStatus(submitPayload); + const normalizedSubmitPayload = normalizeGrsaiPayload(submitPayload); + + if ( + submitStatus === "succeeded" || + submitStatus === "success" || + submitStatus === "completed" || + hasGrsaiImages(submitPayload) + ) { + return normalizedSubmitPayload; + } + + if (submitStatus === "failed" || submitStatus === "error") { + throw new Error(`图片生成失败: ${getGrsaiErrorMessage(submitPayload)}`); + } + + const taskId = getGrsaiTaskId(submitPayload); + if (!taskId) { + logger.error( + `${params.apiLogPrefix} Async submit response has no task id:`, + JSON.stringify(submitPayload).substring(0, 500) + ); + throw new Error("GRSAI Nano Banana 异步任务提交失败:未返回任务 ID"); + } + + logger.debug(`${params.apiLogPrefix} Async task submitted:`, { taskId }); + return pollGrsaiNanoBananaResult({ + baseURL: params.baseURL, + apiKey: params.apiKey, + taskId, + timeoutMs: pollTimeoutMs, + apiLogPrefix: params.apiLogPrefix, + }); +} + /** * 发送 Gemini 图像生成/编辑请求 * 使用独立的请求格式,不依赖 OpenAI SDK @@ -463,6 +716,10 @@ async function sendGeminiRequest( } ): Promise { const isGptImageModel = /^gpt-image/i.test(modelId); + const shouldUseGrsaiAsyncPolling = + !isGptImageModel && + /nano-banana/i.test(modelId) && + /(?:grsai|dakka)/i.test(baseURL); const apiLogPrefix = isGptImageModel ? "[Grsai GPT Image]" : "[Gemini Image]"; // GPT Image 和 Nano Banana 在 Grsai 上是两套接口,不能混用同一个 endpoint。 const url = isGptImageModel @@ -552,6 +809,16 @@ async function sendGeminiRequest( logger.debug(`${apiLogPrefix} Request body (truncated):`, JSON.stringify(requestBody).substring(0, 500) + "..."); + if (shouldUseGrsaiAsyncPolling) { + return submitAndPollGrsaiNanoBanana({ + baseURL, + apiKey, + requestBody, + imageParams, + apiLogPrefix, + }); + } + // 根据分辨率动态设置超时时间 const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; // 4K: 5分钟, 其他: 2分钟 logger.debug(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);