fix: poll grsai nano banana tasks asynchronously

This commit is contained in:
Your Name 2026-06-05 11:52:57 +08:00
parent 4a36630b57
commit 14dbe3efeb

View File

@ -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<string, any> {
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<Response> {
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<any> {
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<any> {
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<string, any>;
imageParams?: {
aspectRatio?: string;
imageSize?: string;
};
apiLogPrefix: string;
}): Promise<any> {
const submitUrl = `${params.baseURL.replace(/\/+$/, "")}/v1/draw/nano-banana`;
const body: Record<string, any> = {
...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<GeminiResponse> {
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"}`);