Handle long running custom image jobs
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 : '网络错误,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : '网络错误,请重试'));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user