From 24eab34305e883c1dc2e83c17362dc50e5152dea Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 9 May 2026 06:21:38 +0000 Subject: [PATCH] Handle long running custom image jobs --- src/app/api/generate/image/route.ts | 35 ++++++++++++--- src/components/create/image-to-image.tsx | 13 +++--- src/components/create/text-to-image.tsx | 9 ++-- src/lib/custom-api-fetch.ts | 55 ++++++++++++++---------- src/lib/generation-job-client.ts | 16 ++++++- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/src/app/api/generate/image/route.ts b/src/app/api/generate/image/route.ts index 4cbd051..26c9733 100644 --- a/src/app/api/generate/image/route.ts +++ b/src/app/api/generate/image/route.ts @@ -22,7 +22,7 @@ interface CustomApiConfig { systemApiId?: string; } -const GENERATION_TIMEOUT = 300_000; +const GENERATION_TIMEOUT = Number(process.env.IMAGE_GENERATION_TIMEOUT_MS || 900_000); const GENERATION_TIMEOUT_SECONDS = GENERATION_TIMEOUT / 1000; const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024); @@ -202,7 +202,7 @@ async function fetchCustomImageGeneration( endpoint, { method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) }, GENERATION_TIMEOUT, - 1, + 0, ); if (!response.ok) { @@ -396,6 +396,30 @@ function objectKeysFromUnknown(value: unknown): string[] { */ function extractImagesFromGenerationsResponse(data: Record): string[] { const images: string[] = []; + const visit = (value: unknown, depth = 0) => { + if (depth > 6 || !value) return; + if (typeof value === 'string') { + if (value.startsWith('data:image/') || /^https?:\/\/[^\s"'<>]+/i.test(value)) images.push(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) visit(item, depth + 1); + return; + } + if (typeof value !== 'object') return; + + const object = value as Record; + if (typeof object.b64_json === 'string') images.push(`data:image/png;base64,${object.b64_json}`); + if (typeof object.url === 'string') visit(object.url, depth + 1); + if (typeof object.image_url === 'string') visit(object.image_url, depth + 1); + if (typeof object.image === 'string') visit(object.image, depth + 1); + if (typeof object.output === 'string') visit(object.output, depth + 1); + if (typeof object.result === 'string') visit(object.result, depth + 1); + for (const key of ['data', 'images', 'image_urls', 'output', 'result', 'results', 'message', 'content']) { + if (key in object) visit(object[key], depth + 1); + } + }; + if (Array.isArray(data.data)) { for (const item of data.data as Array>) { if (typeof item === 'string') { images.push(item); continue; } @@ -409,6 +433,7 @@ function extractImagesFromGenerationsResponse(data: Record): st } else if (typeof data.image_url === 'string') { images.push(data.image_url); } + visit(data); const streamEvents = data.__streamEvents; if (Array.isArray(streamEvents)) { @@ -418,7 +443,7 @@ function extractImagesFromGenerationsResponse(data: Record): st } } - return images; + return Array.from(new Set(images)); } /** Track which strategy produced a result */ @@ -454,7 +479,7 @@ async function tryImageStrategy( body: JSON.stringify(body), }, GENERATION_TIMEOUT, - 1, + 0, ); if (response.ok) { @@ -557,7 +582,7 @@ async function tryEditsWithFormData( body: bodyBuffer, }, GENERATION_TIMEOUT, - 1, + 0, ); if (response.ok) { diff --git a/src/components/create/image-to-image.tsx b/src/components/create/image-to-image.tsx index 6438648..ff31b6b 100644 --- a/src/components/create/image-to-image.tsx +++ b/src/components/create/image-to-image.tsx @@ -28,7 +28,7 @@ import { Sparkles, Loader2, Download, Upload, Wand2, Image as ImageIcon, History import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store'; import { addCreditRecord } from '@/lib/credit-records-store'; import { downloadFile } from '@/lib/utils'; -import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client'; +import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client'; import { useSiteConfig } from '@/lib/site-config'; import { toast } from 'sonner'; import Link from 'next/link'; @@ -355,7 +355,7 @@ export function ImageToImagePanel() { const data = await runGenerationJob<{ images?: string[]; error?: string }>( 'image', requestBody, - { timeoutMs: 300_000, onStatus: setGenerationJobStatus }, + { timeoutMs: 900_000, onStatus: setGenerationJobStatus }, ); await runGenerationFinalCountdown(setFinalCountdownSeconds, 3); if (data.images && data.images.length > 0) { @@ -387,9 +387,12 @@ export function ImageToImagePanel() { setGenerationError(createGenerationError(data.error || '图片生成失败')); } } catch (err: unknown) { - if (err instanceof DOMException && err.name === 'AbortError') { - setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率')); - } else { + if (err instanceof GenerationJobStillRunningError) { + setGenerationError(null); + toast.info('生成任务仍在执行,可稍后在创作历史中查看'); + } else if (err instanceof DOMException && err.name === 'AbortError') { + setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率')); + } else { setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试')); } } diff --git a/src/components/create/text-to-image.tsx b/src/components/create/text-to-image.tsx index 796a961..3dda22d 100644 --- a/src/components/create/text-to-image.tsx +++ b/src/components/create/text-to-image.tsx @@ -27,7 +27,7 @@ import { Sparkles, Loader2, Download, Wand2, Image as ImageIcon, History, Chevro import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store'; import { addCreditRecord } from '@/lib/credit-records-store'; import { downloadFile } from '@/lib/utils'; -import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client'; +import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client'; import { useSiteConfig } from '@/lib/site-config'; import { toast } from 'sonner'; import Link from 'next/link'; @@ -227,7 +227,7 @@ export function TextToImagePanel() { const data = await runGenerationJob<{ images?: string[]; error?: string }>( 'image', requestBody, - { timeoutMs: 300_000, onStatus: setGenerationJobStatus }, + { timeoutMs: 900_000, onStatus: setGenerationJobStatus }, ); await runGenerationFinalCountdown(setFinalCountdownSeconds, 3); if (data.images && data.images.length > 0) { @@ -258,7 +258,10 @@ export function TextToImagePanel() { setGenerationError(createGenerationError(data.error || '图片生成失败')); } } catch (err: unknown) { - if (err instanceof DOMException && err.name === 'AbortError') { + if (err instanceof GenerationJobStillRunningError) { + setGenerationError(null); + toast.info('生成任务仍在执行,可稍后在创作历史中查看'); + } else if (err instanceof DOMException && err.name === 'AbortError') { setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率')); } else { setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试')); diff --git a/src/lib/custom-api-fetch.ts b/src/lib/custom-api-fetch.ts index 5a65556..b7a2088 100644 --- a/src/lib/custom-api-fetch.ts +++ b/src/lib/custom-api-fetch.ts @@ -226,31 +226,42 @@ export async function parseCustomApiJsonWithProgress( const streamEvents: unknown[] = []; let streamText = ''; - while (true) { - const { value, done } = await reader.read(); - if (value) buffer += decoder.decode(value, { stream: !done }); - const lines = buffer.split(/\r?\n/); - buffer = done ? '' : lines.pop() || ''; + try { + while (true) { + const { value, done } = await reader.read(); + if (value) buffer += decoder.decode(value, { stream: !done }); + const lines = buffer.split(/\r?\n/); + buffer = done ? '' : lines.pop() || ''; - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line === 'data: [DONE]' || line === '[DONE]') continue; - const payload = line.startsWith('data:') ? line.slice(5).trim() : line; - if (!payload || payload === '[DONE]') continue; - try { - const parsed = JSON.parse(payload); - lastJson = parsed; - streamEvents.push(parsed); - streamText += extractStreamingTextDelta(parsed); - const progress = extractUpstreamProgress(parsed); - if (progress) await onProgress?.(progress); - } catch { - const progress = extractUpstreamProgress({ message: payload }); - if (progress) await onProgress?.(progress); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line === 'data: [DONE]' || line === '[DONE]') continue; + if (line.startsWith('event:')) { + const eventName = line.slice(6).trim(); + const progress = extractUpstreamProgress({ message: `event: ${eventName}` }); + if (progress) await onProgress?.(progress); + continue; + } + const payload = line.startsWith('data:') ? line.slice(5).trim() : line; + if (!payload || payload === '[DONE]') continue; + try { + const parsed = JSON.parse(payload); + lastJson = parsed; + streamEvents.push(parsed); + streamText += extractStreamingTextDelta(parsed); + const progress = extractUpstreamProgress(parsed); + if (progress) await onProgress?.(progress); + } catch { + const progress = extractUpstreamProgress({ message: payload }); + if (progress) await onProgress?.(progress); + } } - } - if (done) break; + if (done) break; + } + } catch (error) { + if (!lastJson && !streamText) throw error; + console.warn('[Custom API Stream] stream ended with read error after receiving data:', error instanceof Error ? error.message : error); } if (buffer.trim()) { diff --git a/src/lib/generation-job-client.ts b/src/lib/generation-job-client.ts index 81a18f8..f5e6d57 100644 --- a/src/lib/generation-job-client.ts +++ b/src/lib/generation-job-client.ts @@ -28,6 +28,16 @@ type GenerationJobOptions = { onStatus?: (status: GenerationJobStatus) => void; }; +export class GenerationJobStillRunningError extends Error { + status: GenerationJobStatus | null; + + constructor(status: GenerationJobStatus | null) { + super('生成任务仍在执行,请稍后在创作历史中查看'); + this.name = 'GenerationJobStillRunningError'; + this.status = status; + } +} + function getAuthToken(): string | null { try { const raw = window.localStorage.getItem('miaojing_auth'); @@ -76,9 +86,10 @@ export async function runGenerationJob>( status: 'queued', } as GenerationJobStatus); - const timeoutMs = options.timeoutMs ?? (type === 'video' ? 600_000 : 300_000); + const timeoutMs = options.timeoutMs ?? (type === 'video' ? 600_000 : 900_000); const intervalMs = options.intervalMs ?? 2_000; const startedAt = Date.now(); + let lastStatus: GenerationJobStatus | null = null; while (Date.now() - startedAt < timeoutMs) { await sleep(intervalMs); @@ -93,6 +104,7 @@ export async function runGenerationJob>( throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`); } options.onStatus?.(statusData as GenerationJobStatus); + lastStatus = statusData as GenerationJobStatus; if (statusData.status === 'succeeded') { return (statusData.result || {}) as T; @@ -102,5 +114,5 @@ export async function runGenerationJob>( } } - throw new Error('生成任务仍在执行,请稍后在创作历史中查看或重试'); + throw new GenerationJobStillRunningError(lastStatus); }