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");
|
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 图像生成/编辑请求
|
* 发送 Gemini 图像生成/编辑请求
|
||||||
* 使用独立的请求格式,不依赖 OpenAI SDK
|
* 使用独立的请求格式,不依赖 OpenAI SDK
|
||||||
@ -463,6 +716,10 @@ async function sendGeminiRequest(
|
|||||||
}
|
}
|
||||||
): Promise<GeminiResponse> {
|
): Promise<GeminiResponse> {
|
||||||
const isGptImageModel = /^gpt-image/i.test(modelId);
|
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]";
|
const apiLogPrefix = isGptImageModel ? "[Grsai GPT Image]" : "[Gemini Image]";
|
||||||
// GPT Image 和 Nano Banana 在 Grsai 上是两套接口,不能混用同一个 endpoint。
|
// GPT Image 和 Nano Banana 在 Grsai 上是两套接口,不能混用同一个 endpoint。
|
||||||
const url = isGptImageModel
|
const url = isGptImageModel
|
||||||
@ -552,6 +809,16 @@ async function sendGeminiRequest(
|
|||||||
|
|
||||||
logger.debug(`${apiLogPrefix} Request body (truncated):`, JSON.stringify(requestBody).substring(0, 500) + "...");
|
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分钟
|
const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; // 4K: 5分钟, 其他: 2分钟
|
||||||
logger.debug(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);
|
logger.debug(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user