Handle long running custom image jobs

This commit is contained in:
Codex
2026-05-09 06:21:38 +00:00
parent c8f0c37cd1
commit 24eab34305
5 changed files with 91 additions and 37 deletions

View File

@@ -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, unknown>): 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<string, unknown>;
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<Record<string, unknown>>) {
if (typeof item === 'string') { images.push(item); continue; }
@@ -409,6 +433,7 @@ function extractImagesFromGenerationsResponse(data: Record<string, unknown>): 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<string, unknown>): 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) {

View File

@@ -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,7 +387,10 @@ export function ImageToImagePanel() {
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 : '网络错误,请重试'));

View File

@@ -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 : '网络错误,请重试'));

View File

@@ -226,6 +226,7 @@ export async function parseCustomApiJsonWithProgress(
const streamEvents: unknown[] = [];
let streamText = '';
try {
while (true) {
const { value, done } = await reader.read();
if (value) buffer += decoder.decode(value, { stream: !done });
@@ -235,6 +236,12 @@ export async function parseCustomApiJsonWithProgress(
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 {
@@ -252,6 +259,10 @@ export async function parseCustomApiJsonWithProgress(
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()) {
try {

View File

@@ -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<T extends Record<string, unknown>>(
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<T extends Record<string, unknown>>(
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<T extends Record<string, unknown>>(
}
}
throw new Error('生成任务仍在执行,请稍后在创作历史中查看或重试');
throw new GenerationJobStillRunningError(lastStatus);
}