fix: poll grsai nano banana tasks asynchronously
This commit is contained in:
parent
4a36630b57
commit
14dbe3efeb
@ -447,6 +447,259 @@ function formatImageResponse(
|
||||
return images.map((img) => ``).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"}`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user