From b032774e8e6aabf32bb17235ee9af0656628c231 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 22 Jun 2026 14:38:59 +0800 Subject: [PATCH] fix: improve image generation timeout failover --- app/(chat)/api/chat/_lib/image-failover.ts | 147 +++++++++++++++++- app/(chat)/api/chat/route.ts | 10 +- app/api/canvas/generate/image/route.ts | 2 +- app/api/canvas/text-edit/route.ts | 2 +- app/api/external/image/generate/route.ts | 2 +- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 2 +- components/canvas/CanvasChatPanel.tsx | 2 +- components/generation-dot-placeholder.tsx | 29 +++- components/image-generation-loading.tsx | 48 +++++- lib/ai/providers/alibaba-image.ts | 47 ++++-- lib/ai/providers/bfl-image.ts | 2 +- lib/ai/providers/fal-image.ts | 2 +- lib/ai/providers/gemini-image.ts | 8 +- lib/ai/providers/huanke-gemini.ts | 4 +- lib/ai/providers/krea.ts | 2 +- lib/ai/providers/midjourney.ts | 2 +- lib/ai/providers/openai-image.ts | 4 +- lib/ai/providers/runway.ts | 16 +- lib/ai/providers/stepfun-image.ts | 2 +- .../direct-agent-image-edit-executor.ts | 2 +- lib/ai/runtime/model-health.ts | 68 +++++++- 21 files changed, 343 insertions(+), 60 deletions(-) diff --git a/app/(chat)/api/chat/_lib/image-failover.ts b/app/(chat)/api/chat/_lib/image-failover.ts index e31efea5f..afe10ce4b 100644 --- a/app/(chat)/api/chat/_lib/image-failover.ts +++ b/app/(chat)/api/chat/_lib/image-failover.ts @@ -109,8 +109,93 @@ export type ImageFallbackCandidate = { provider: any; configured: boolean; sameProvider: boolean; + nameSimilarity: number; + sameName: boolean; }; +const IMAGE_FAILOVER_SAME_NAME_THRESHOLD = 0.5; + +function getModelRefKeys(item: { model?: any } | null | undefined) { + return [ + String(item?.model?.id || "").trim(), + String(item?.model?.modelId || "").trim(), + ].filter(Boolean); +} + +function normalizeModelNameForSimilarity(value: unknown) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[「」『』【】[\]()(){}]/g, " ") + .replace(/[_/|::,,.。+]+/g, "-") + .replace(/\s+/g, " "); +} + +function tokenizeModelNameForSimilarity(value: unknown) { + const normalized = normalizeModelNameForSimilarity(value); + const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, ""); + const tokens = new Set( + normalized + .split(/[^a-z0-9\u4e00-\u9fa5]+/g) + .map((item) => item.trim()) + .filter((item) => item.length >= 2) + ); + + if (compact.length >= 2) { + tokens.add(compact); + for (let index = 0; index < compact.length - 1; index += 1) { + tokens.add(compact.slice(index, index + 2)); + } + } + + return tokens; +} + +function scoreModelNameSimilarity( + source: { model?: any } | null | undefined, + candidate: { model?: any } | null | undefined +) { + const sourceNames = [ + source?.model?.displayName, + source?.model?.modelId, + ].filter(Boolean); + const candidateNames = [ + candidate?.model?.displayName, + candidate?.model?.modelId, + ].filter(Boolean); + + let bestScore = 0; + for (const sourceName of sourceNames) { + const sourceTokens = tokenizeModelNameForSimilarity(sourceName); + if (sourceTokens.size === 0) continue; + for (const candidateName of candidateNames) { + const candidateTokens = tokenizeModelNameForSimilarity(candidateName); + if (candidateTokens.size === 0) continue; + let overlap = 0; + sourceTokens.forEach((token) => { + if (candidateTokens.has(token)) overlap += 1; + }); + const score = overlap / Math.max(sourceTokens.size, candidateTokens.size); + bestScore = Math.max(bestScore, score); + } + } + + return bestScore; +} + +function resolveCurrentImageModelEntry( + allModels: Array<{ model: any; provider: any }>, + refs: string[] +) { + const normalizedRefs = new Set(refs.map((item) => String(item || "").trim()).filter(Boolean)); + if (normalizedRefs.size === 0) return null; + return ( + allModels.find((item) => + getModelRefKeys(item).some((key) => normalizedRefs.has(key)) + ) || null + ); +} + function messagePartHasImageInput(part: any) { if (!part || typeof part !== "object") return false; if (part.type === "file" || part.type === "image" || part.type === "image_url") { @@ -171,6 +256,10 @@ export function buildImageFallbackCandidates( } ) { const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options; + const currentEntry = resolveCurrentImageModelEntry(allModels, [ + currentModelId || "", + currentModelKey || "", + ]); const configuredRefs = configuredFallbackIds .map((item) => String(item || "").trim()) .filter(Boolean); @@ -210,6 +299,7 @@ export function buildImageFallbackCandidates( const candidates: ImageFallbackCandidate[] = []; const seenModelIds = new Set(); + const candidateOrder = new Map(); const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => { if (!item?.model) return; const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId); @@ -217,11 +307,15 @@ export function buildImageFallbackCandidates( const modelId = String(item.model.id || ""); if (!modelId || seenModelIds.has(modelId)) return; seenModelIds.add(modelId); + candidateOrder.set(modelId, candidateOrder.size); + const nameSimilarity = scoreModelNameSimilarity(currentEntry, item); candidates.push({ model: item.model, provider: item.provider, configured, sameProvider, + nameSimilarity, + sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD, }); }; @@ -235,7 +329,24 @@ export function buildImageFallbackCandidates( .forEach((item) => pushCandidate(item, false)); } - return candidates; + if (currentEntry) { + allModels + .filter((item) => scoreModelNameSimilarity(currentEntry, item) >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD) + .forEach((item) => pushCandidate(item, false)); + } + + return candidates.sort((left, right) => { + if (left.sameName !== right.sameName) return left.sameName ? -1 : 1; + if (left.configured !== right.configured) return left.configured ? -1 : 1; + if (left.sameProvider !== right.sameProvider) return left.sameProvider ? -1 : 1; + if (right.nameSimilarity !== left.nameSimilarity) { + return right.nameSimilarity - left.nameSimilarity; + } + return ( + (candidateOrder.get(String(left.model.id || "")) ?? 0) - + (candidateOrder.get(String(right.model.id || "")) ?? 0) + ); + }); } export async function generateImageWithFailoverCandidate(params: { @@ -302,10 +413,15 @@ export function getImageCrossProviderCandidates( allModels: any[], options: { currentModelId: string; + currentModelKey?: string; currentProviderId?: string; providerPriority: Record; } ) { + const currentEntry = resolveCurrentImageModelEntry(allModels, [ + options.currentModelId, + options.currentModelKey || "", + ]); return allModels .filter((m) => { if ( @@ -328,10 +444,37 @@ export function getImageCrossProviderCandidates( } return true; }) + .map((item, index) => { + const nameSimilarity = scoreModelNameSimilarity(currentEntry, item); + return { + ...item, + __imageFailoverIndex: index, + __imageFailoverNameSimilarity: nameSimilarity, + __imageFailoverSameName: + nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD, + }; + }) .sort((a, b) => { + if (a.__imageFailoverSameName !== b.__imageFailoverSameName) { + return a.__imageFailoverSameName ? -1 : 1; + } + if (b.__imageFailoverNameSimilarity !== a.__imageFailoverNameSimilarity) { + return b.__imageFailoverNameSimilarity - a.__imageFailoverNameSimilarity; + } const left = options.providerPriority[a.provider.type as string] ?? 99; const right = options.providerPriority[b.provider.type as string] ?? 99; if (left !== right) return left - right; - return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0); + const sortOrderDiff = (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0); + if (sortOrderDiff !== 0) return sortOrderDiff; + return (a.__imageFailoverIndex ?? 0) - (b.__imageFailoverIndex ?? 0); + }) + .map((item) => { + const { + __imageFailoverIndex, + __imageFailoverNameSimilarity, + __imageFailoverSameName, + ...candidate + } = item; + return candidate; }); } diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 399f05d5f..5bd0282ab 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -598,7 +598,7 @@ function mergeVideoParamsWithAgentRecommendation(params: { return Object.keys(merged).length > 0 ? (merged as ChatVideoParams) : undefined; } -export const maxDuration = 600; // 10 分钟,用于支持图片编辑等耗时任务 +export const maxDuration = 1200; // 20 分钟,支持单模型 10 分钟 + 故障迁移备用模型等待 let globalStreamContext: ResumableStreamContext | null = null; const inFlightChatRequests = new Map(); @@ -6506,6 +6506,7 @@ export async function POST(request: Request) { }; const crossProviderCandidates = getImageCrossProviderCandidates(allModels, { currentModelId: finalModelId, + currentModelKey: actualModelId, currentProviderId: currentModelConfig?.providerId, providerPriority, }).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || ""))); @@ -7214,6 +7215,7 @@ export async function POST(request: Request) { }; const crossProviderCandidates = getImageCrossProviderCandidates(allModels, { currentModelId: finalModelId, + currentModelKey: actualModelId, currentProviderId: alibabaModelConfig?.providerId, providerPriority, }); @@ -7640,6 +7642,7 @@ export async function POST(request: Request) { }; const crossProviderCandidates = getImageCrossProviderCandidates(allModels, { currentModelId: finalModelId, + currentModelKey: actualModelId, currentProviderId: volcanoModelConfig?.providerId, providerPriority, }); @@ -8409,7 +8412,7 @@ export async function POST(request: Request) { try { const tryGenerateWithOpenAICompatModel = async (modelToUse: any) => { - const imageRequestTimeoutMs = 180_000; + const imageRequestTimeoutMs = 600_000; const generated = await generateText({ model: modelToUse, system: systemPrompt({ selectedChatModel: finalModelId, requestHints }), @@ -8508,6 +8511,7 @@ export async function POST(request: Request) { }; const crossProviderCandidates = getImageCrossProviderCandidates(allModels, { currentModelId: finalModelId, + currentModelKey: actualModelId, currentProviderId: currentModelConfig?.providerId, providerPriority, }); @@ -9292,7 +9296,7 @@ export async function POST(request: Request) { } // 常规文本模型使用 streamText - const streamRequestTimeoutMs = resolvedModelSupportsImageGen ? 180_000 : 90_000; + const streamRequestTimeoutMs = resolvedModelSupportsImageGen ? 600_000 : 90_000; const shouldDisableSmoothStream = resolvedModelSupportsImageGen || isVolcanoVideoModel; diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index b2d13dfbc..4d605a07a 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -16,7 +16,7 @@ import { type ImageToolCreditFeature, } from "@/lib/points/config"; -export const maxDuration = 600; +export const maxDuration = 1200; interface GenerateImageRequest extends CanvasGenerateImageRequest { async?: boolean; diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index 7e12e5164..538c8ca94 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -21,7 +21,7 @@ import { createLogger } from "@/lib/observability/logger"; import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; -export const maxDuration = 120; +export const maxDuration = 1200; const logger = createLogger("canvas-text-edit"); const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000; diff --git a/app/api/external/image/generate/route.ts b/app/api/external/image/generate/route.ts index b6bf59261..9473da466 100644 --- a/app/api/external/image/generate/route.ts +++ b/app/api/external/image/generate/route.ts @@ -19,7 +19,7 @@ import { } from "@/lib/external-image-api/service"; export const runtime = "nodejs"; -export const maxDuration = 360; +export const maxDuration = 1200; const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__"; const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2; diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 0ead2d8b5..af9fe2203 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -770,7 +770,7 @@ type DirectDetailReviewResult = { }; const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600; -const AGENT_IMAGE_TASK_TIMEOUT_MS = 20 * 60 * 1000; +const AGENT_IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000; const AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT = 4; function sleep(ms: number) { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 3267e4dce..ded1af6d0 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -435,7 +435,7 @@ const CANVAS_AGENT_SECTION_TITLES = new Set([ "输出设置", ]); const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600; -const CANVAS_IMAGE_TASK_TIMEOUT_MS = 20 * 60 * 1000; +const CANVAS_IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000; const CANVAS_GENERATION_NODE_GAP = 56; const CANVAS_GENERATION_STACK_GAP = 28; const CANVAS_GENERATION_COLLISION_GAP = 32; diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx index a55d2cd6f..b8b49a765 100644 --- a/components/generation-dot-placeholder.tsx +++ b/components/generation-dot-placeholder.tsx @@ -100,6 +100,13 @@ const GENERATION_STATUS_TITLES = [ "正在完成本次生成", "正在即将展示结果", ]; +const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000; +const LONG_WAIT_STATUS_TITLES = [ + "服务繁忙,排队处理中", + "高峰期生成较慢,请耐心等候", + "仍在保持连接,等待模型返回", + "结果返回后会自动展示", +]; const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => { const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400]; @@ -136,6 +143,21 @@ function getStatusTitleState(elapsedMs: number, initialTitle?: string) { }; } +function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) { + const titles = initialTitle + ? [initialTitle, ...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle)] + : LONG_WAIT_STATUS_TITLES; + const elapsedAfterThreshold = Math.max(0, elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS); + const duration = 7600; + const index = Math.floor(elapsedAfterThreshold / duration) % titles.length; + + return { + index, + title: titles[index] || titles[0] || PHASE_TITLES.processing, + elapsedInTitle: elapsedAfterThreshold % duration, + }; +} + function getTypewriterText(text: string, elapsedInTitle: number, index: number) { const chars = Array.from(text); if (chars.length === 0) return ""; @@ -383,7 +405,12 @@ export function GenerationDotPlaceholder({ const resolvedProgress = clampProgress( typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs) ); - const statusTitleState = getStatusTitleState(elapsedMs, title); + const isLongWaitTitle = + (phase === "processing" || phase === "finalizing") && + elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS; + const statusTitleState = isLongWaitTitle + ? getLongWaitTitleState(elapsedMs, title) + : getStatusTitleState(elapsedMs, title); const rotatingTitle = getTypewriterText( statusTitleState.title, statusTitleState.elapsedInTitle, diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx index d3ff25c31..adb643971 100644 --- a/components/image-generation-loading.tsx +++ b/components/image-generation-loading.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo } from "react"; +import { memo, useEffect, useState } from "react"; import { ChevronDown, CircleDot } from "lucide-react"; import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; @@ -62,9 +62,13 @@ const LONG_PROCESSING_HINTS = [ "正在保存图片并同步到对话。", "正在准备把结果显示到画布。", ]; +const LONG_WAIT_SECONDS = 5 * 60; +const LONG_WAIT_HINT = + "服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。"; function getLongProcessingHint(phase: NonNullable, elapsedSeconds: number) { if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase]; + if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT; const index = Math.min( LONG_PROCESSING_HINTS.length - 1, Math.floor(Math.max(0, elapsedSeconds) / 15) @@ -95,19 +99,50 @@ export const ImageGenerationLoading = memo( modelName, providerType, phase = "processing", - elapsedSeconds = 0, + elapsedSeconds, queuePosition, sameModelRunningCount, concurrencyLimit, estimatedWaitMs, }: ImageGenerationLoadingProps) => { + const shouldTrackElapsed = + phase === "queued" || phase === "processing" || phase === "finalizing"; + const [internalElapsedSeconds, setInternalElapsedSeconds] = useState(0); + + useEffect(() => { + if (typeof elapsedSeconds === "number") return; + if (!shouldTrackElapsed) { + setInternalElapsedSeconds(0); + return; + } + + const startedAt = Date.now(); + setInternalElapsedSeconds(0); + const timer = window.setInterval(() => { + setInternalElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + + return () => window.clearInterval(timer); + }, [elapsedSeconds, modelName, phase, providerType, shouldTrackElapsed]); + const count = Math.min(Math.max(imageCount, 1), maxImages); const resolvedModelName = modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型"; - const estimateRangeLabel = providerType === "grsai-gpt-image" ? "2~5分钟" : "60~120秒"; + const resolvedElapsedSeconds = + typeof elapsedSeconds === "number" ? elapsedSeconds : internalElapsedSeconds; + const isLongWait = + resolvedElapsedSeconds >= LONG_WAIT_SECONDS && + (phase === "processing" || phase === "finalizing"); + const estimateRangeLabel = isLongWait + ? "单次最长约10分钟" + : providerType === "grsai-gpt-image" + ? "2~5分钟" + : "60~120秒"; const elapsedLabel = - elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`; - const phaseHint = getLongProcessingHint(phase, elapsedSeconds); + resolvedElapsedSeconds > 0 + ? `已等待 ${resolvedElapsedSeconds}s` + : `预计 ${estimateRangeLabel}`; + const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds); const queueLine = phase === "queued" && Number.isFinite(queuePosition) ? Math.max(0, Number(queuePosition || 0)) > 0 @@ -149,6 +184,7 @@ export const ImageGenerationLoading = memo( } compact={count > 1} phase={phase} + title={isLongWait ? "服务繁忙,排队处理中" : undefined} />
@@ -157,7 +193,7 @@ export const ImageGenerationLoading = memo(
{queueLine ?
{queueLine}
: null} {queueDetailLine ?
{queueDetailLine}
: null} - {(phase !== "processing" || elapsedSeconds > 0) && ( + {(phase !== "processing" || resolvedElapsedSeconds > 0) && (
{phaseHint} {elapsedLabel}
diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index 3aae63608..d00f20d2e 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -49,6 +49,7 @@ interface AlibabaImageResponse { } const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000; +const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000; const ALIBABA_MIN_SIZE = 512; const ALIBABA_MAX_SIZE = 2048; const ALIBABA_OUTPUT_SIZE_MULTIPLE = 2; @@ -451,24 +452,36 @@ async function sendAlibabaImageRequest( console.log("[Alibaba Image] Model:", modelId); console.log("[Alibaba Image] Parameters:", JSON.stringify(requestBody.parameters)); - const response = await fetch(baseURL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(requestBody), - }); - - const data = await response.json(); - - if (!response.ok || data.code) { - console.error("[Alibaba Image] API Error:", data); - throw new Error(data.message || `API request failed: ${response.status}`); + const ctx = createAbortSignal(ALIBABA_IMAGE_GENERATION_TIMEOUT_MS); + + try { + const response = await fetch(baseURL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + signal: ctx.signal, + }); + + const data = await response.json(); + + if (!response.ok || data.code) { + console.error("[Alibaba Image] API Error:", data); + throw new Error(data.message || `API request failed: ${response.status}`); + } + + console.log("[Alibaba Image] Response received, request_id:", data.request_id); + return data; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("阿里图片生成请求超时(600秒),请稍后重试"); + } + throw error; + } finally { + ctx.clear(); } - - console.log("[Alibaba Image] Response received, request_id:", data.request_id); - return data; } /** diff --git a/lib/ai/providers/bfl-image.ts b/lib/ai/providers/bfl-image.ts index 1e2ccee86..f0026fcfd 100644 --- a/lib/ai/providers/bfl-image.ts +++ b/lib/ai/providers/bfl-image.ts @@ -42,7 +42,7 @@ type BflSubmitResponse = { detail?: string; }; -const DEFAULT_TIMEOUT_MS = 180_000; +const DEFAULT_TIMEOUT_MS = 600_000; const TRAILING_SLASHES_REGEX = /\/+$/; const TRAILING_V1_REGEX = /\/v1$/i; const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i; diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 0b80e1afb..0de539b7e 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -46,7 +46,7 @@ const DEFAULT_ASPECT_RATIO = "auto"; const DEFAULT_RESOLUTION = "1K"; const DEFAULT_OUTPUT_FORMAT = "png"; const DEFAULT_SAFETY_TOLERANCE = "4"; -const DEFAULT_TIMEOUT_MS = 300000; +const DEFAULT_TIMEOUT_MS = 600000; const FAL_GENERIC_MAX_EDGE = 4096; const FAL_GENERIC_MAX_PIXELS = 4096 * 4096; const GPT_IMAGE_2_MAX_EDGE = 3840; diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 588b9ab72..86b34563d 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -640,8 +640,8 @@ function formatImageResponse( const GRSAI_NANO_BANANA_SUBMIT_TIMEOUT_MS = 30000; const GRSAI_NANO_BANANA_POLL_TIMEOUT_MS = 30000; const GRSAI_NANO_BANANA_POLL_INTERVAL_MS = 5000; -const GRSAI_GPT_IMAGE_TIMEOUT_MS = 12 * 60 * 1000; -const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 14 * 60 * 1000; +const GRSAI_GPT_IMAGE_TIMEOUT_MS = 10 * 60 * 1000; +const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 10 * 60 * 1000; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -1001,7 +1001,7 @@ async function submitAndPollGrsaiNanoBanana(params: { webHook: "-1", }; const imageSize = String(params.imageParams?.imageSize || "").toUpperCase(); - const pollTimeoutMs = imageSize === "4K" ? 600000 : 300000; + const pollTimeoutMs = 600000; logger.debug(`${params.apiLogPrefix} Submitting async Nano Banana task:`, { model: body.model, @@ -1218,7 +1218,7 @@ async function sendGeminiRequest( } // 根据分辨率动态设置超时时间 - const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; // 4K: 5分钟, 其他: 2分钟 + const timeout = 600000; // 所有生图模型最长等待 10 分钟 logger.debug(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`); const controller = new AbortController(); diff --git a/lib/ai/providers/huanke-gemini.ts b/lib/ai/providers/huanke-gemini.ts index 363774219..23b1c054b 100644 --- a/lib/ai/providers/huanke-gemini.ts +++ b/lib/ai/providers/huanke-gemini.ts @@ -531,8 +531,8 @@ async function sendHuankeGeminiRequest( console.log("[Huanke Gemini] Request body (truncated):", JSON.stringify(requestBody).substring(0, 500) + "..."); - // 根据分辨率动态设置超时时间 - const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; + // 所有生图模型最长等待 10 分钟 + const timeout = 600000; console.log(`[Huanke Gemini] Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`); const controller = new AbortController(); diff --git a/lib/ai/providers/krea.ts b/lib/ai/providers/krea.ts index ba699cd19..b2d3b6772 100644 --- a/lib/ai/providers/krea.ts +++ b/lib/ai/providers/krea.ts @@ -40,7 +40,7 @@ interface UploadedImage { const DEFAULT_BASE_URL = "https://api.krea.ai"; const IMAGE_POLL_INTERVAL_MS = 2000; const VIDEO_POLL_INTERVAL_MS = 5000; -const IMAGE_TIMEOUT_MS = 5 * 60 * 1000; +const IMAGE_TIMEOUT_MS = 10 * 60 * 1000; const VIDEO_TIMEOUT_MS = 20 * 60 * 1000; const KREA_IMAGE_ASPECT_RATIOS = [ "21:9", diff --git a/lib/ai/providers/midjourney.ts b/lib/ai/providers/midjourney.ts index 951564853..8cc722410 100644 --- a/lib/ai/providers/midjourney.ts +++ b/lib/ai/providers/midjourney.ts @@ -38,7 +38,7 @@ interface MidjourneyTaskResult { buttons?: MidjourneyTaskButton[]; } -const DEFAULT_TIMEOUT_MS = 300000; +const DEFAULT_TIMEOUT_MS = 600000; const POLL_INTERVAL_MS = 3500; const DEFAULT_IMAGE_SIZE = "2K"; diff --git a/lib/ai/providers/openai-image.ts b/lib/ai/providers/openai-image.ts index 2c3a05085..fdf610ebc 100644 --- a/lib/ai/providers/openai-image.ts +++ b/lib/ai/providers/openai-image.ts @@ -27,8 +27,8 @@ interface UploadedImage { } const DEFAULT_IMAGE_SIZE = "2K"; -const DEFAULT_TIMEOUT_MS = 300000; -const LONG_RUNNING_COMFLY_TIMEOUT_MS = 590000; +const DEFAULT_TIMEOUT_MS = 600000; +const LONG_RUNNING_COMFLY_TIMEOUT_MS = 600000; const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400; const OPENAI_IMAGE_DIMENSION_MULTIPLE = 16; const COMFLY_GPT_IMAGE_STABLE_EDIT_EDGE = 1536; diff --git a/lib/ai/providers/runway.ts b/lib/ai/providers/runway.ts index c64567e79..cfe1b1637 100644 --- a/lib/ai/providers/runway.ts +++ b/lib/ai/providers/runway.ts @@ -39,7 +39,8 @@ interface UploadedImage { const RUNWAY_VERSION = "2024-11-06"; const DEFAULT_BASE_URL = "https://api.dev.runwayml.com"; const TASK_POLL_INTERVAL_MS = 5000; -const TASK_TIMEOUT_MS = 15 * 60 * 1000; +const IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000; +const VIDEO_TASK_TIMEOUT_MS = 15 * 60 * 1000; const IMAGE_RESOLUTION_MAP: Record> = { "1K": { @@ -376,8 +377,13 @@ async function createTask(baseURL: string, apiKey: string, path: string, body: R return taskId; } -async function waitForTaskOutput(baseURL: string, apiKey: string, taskId: string) { - const deadline = Date.now() + TASK_TIMEOUT_MS; +async function waitForTaskOutput( + baseURL: string, + apiKey: string, + taskId: string, + timeoutMs: number = VIDEO_TASK_TIMEOUT_MS +) { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const response = await fetch(joinEndpoint(baseURL, `/v1/tasks/${taskId}`), { @@ -552,7 +558,7 @@ export function createRunway({ apiKey, baseURL }: RunwayConfig) { : {}), }); - const task = await waitForTaskOutput(baseURL, apiKey, taskId); + const task = await waitForTaskOutput(baseURL, apiKey, taskId, IMAGE_TASK_TIMEOUT_MS); const uploadedImages = await uploadRunwayImages(task?.output ?? task); const text = formatImageMarkdown(uploadedImages, imageSize); @@ -624,7 +630,7 @@ export function createRunway({ apiKey, baseURL }: RunwayConfig) { ...(typeof audio === "boolean" ? { audio } : {}), }); - const task = await waitForTaskOutput(baseURL, apiKey, taskId); + const task = await waitForTaskOutput(baseURL, apiKey, taskId, VIDEO_TASK_TIMEOUT_MS); const videoUrl = await resolveRunwayVideoUrl(task?.output ?? task); const text = formatVideoMarkdown(videoUrl); diff --git a/lib/ai/providers/stepfun-image.ts b/lib/ai/providers/stepfun-image.ts index da1b32b55..48a074314 100644 --- a/lib/ai/providers/stepfun-image.ts +++ b/lib/ai/providers/stepfun-image.ts @@ -24,7 +24,7 @@ interface UploadedImage { } const DEFAULT_BASE_URL = "https://api.stepfun.com"; -const DEFAULT_TIMEOUT_MS = 300000; +const DEFAULT_TIMEOUT_MS = 600000; const DEFAULT_CFG_SCALE = 1.0; const DEFAULT_STEPS = 8; const DEFAULT_TEXT_MODE = true; diff --git a/lib/ai/runtime/direct-agent-image-edit-executor.ts b/lib/ai/runtime/direct-agent-image-edit-executor.ts index e82964f1d..18f682f35 100644 --- a/lib/ai/runtime/direct-agent-image-edit-executor.ts +++ b/lib/ai/runtime/direct-agent-image-edit-executor.ts @@ -122,7 +122,7 @@ async function executeDirectAgentEnhanceJob(params: { messageId: params.messageId, sourceImageUrl: params.imageUrl, }, - timeoutMs: 20 * 60 * 1000, + timeoutMs: 10 * 60 * 1000, headers: cookieHeader ? { cookie: cookieHeader, diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index ca7623ebb..0d6e2316b 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -50,6 +50,29 @@ function getErrorMessage(error: unknown) { return normalizeText(error); } +function isExplicitUserCancellation(normalized: string) { + return ( + normalized.includes("user abort") || + normalized.includes("user aborted") || + normalized.includes("user cancelled") || + normalized.includes("user canceled") || + normalized.includes("client aborted") || + normalized.includes("request cancelled by user") || + normalized.includes("request canceled by user") + ); +} + +function isOperationalAbort(error: unknown, normalized: string) { + if (isExplicitUserCancellation(normalized)) return false; + if (error instanceof Error && error.name === "AbortError") return true; + return ( + normalized.includes("this operation was aborted") || + normalized.includes("the operation was aborted") || + normalized.includes("operation was aborted") || + normalized.includes("request aborted") + ); +} + export function classifyAiModelHealthFailure(error: unknown): { eventType: AiModelHealthEventType; severity: AiModelHealthSeverity; @@ -68,6 +91,15 @@ export function classifyAiModelHealthFailure(error: unknown): { }; } + if (isExplicitUserCancellation(normalized)) { + return { + eventType: "error", + severity: "warning", + counted: false, + reason: "user_cancelled", + }; + } + if ( normalized.includes("生成结果未通过替换质检") || normalized.includes("output_moderation") || @@ -76,10 +108,7 @@ export function classifyAiModelHealthFailure(error: unknown): { normalized.includes("content policy") || normalized.includes("safety") || normalized.includes("generate image failed") || - normalized.includes("could not generate images with the given prompts and images") || - normalized.includes("user abort") || - normalized.includes("aborted") || - normalized.includes("cancelled") + normalized.includes("could not generate images with the given prompts and images") ) { return { eventType: "error", @@ -140,6 +169,7 @@ export function classifyAiModelHealthFailure(error: unknown): { } if ( + isOperationalAbort(error, normalized) || normalized.includes("timeout") || normalized.includes("超时") || normalized.includes("524") || @@ -183,6 +213,10 @@ export function classifyAiModelHealthFailure(error: unknown): { normalized.includes("download") || normalized.includes("no image url") || normalized.includes("no image") || + normalized.includes("未返回可用图片") || + normalized.includes("未包含可用图片") || + normalized.includes("未解析到图片") || + normalized.includes("未找到图片数据") || normalized.includes("图片生成失败") ) { return { @@ -203,12 +237,32 @@ export function classifyAiModelHealthFailure(error: unknown): { export function shouldFailoverAiModelError(error: unknown) { const classification = classifyAiModelHealthFailure(error); - if (!classification.counted) return false; - const normalized = getErrorMessage(error).toLowerCase(); - if (classification.eventType === "timeout" || classification.eventType === "download_error") { + + if (classification.reason === "user_cancelled" || classification.reason === "non_provider_failure") { return false; } + + if ( + classification.eventType === "timeout" || + classification.reason === "transport_failure_no_provider_response" + ) { + return true; + } + + if (classification.eventType === "download_error") { + return ( + normalized.includes("no image") || + normalized.includes("未返回可用图片") || + normalized.includes("未包含可用图片") || + normalized.includes("未解析到图片") || + normalized.includes("未找到图片数据") || + normalized.includes("图片生成失败") + ); + } + + if (!classification.counted) return false; + if (classification.eventType === "invalid_model" || classification.eventType === "quota") { return true; }