Handle long running custom image jobs
This commit is contained in:
@@ -22,7 +22,7 @@ interface CustomApiConfig {
|
|||||||
systemApiId?: string;
|
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 GENERATION_TIMEOUT_SECONDS = GENERATION_TIMEOUT / 1000;
|
||||||
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
|
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ async function fetchCustomImageGeneration(
|
|||||||
endpoint,
|
endpoint,
|
||||||
{ method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) },
|
{ method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) },
|
||||||
GENERATION_TIMEOUT,
|
GENERATION_TIMEOUT,
|
||||||
1,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -396,6 +396,30 @@ function objectKeysFromUnknown(value: unknown): string[] {
|
|||||||
*/
|
*/
|
||||||
function extractImagesFromGenerationsResponse(data: Record<string, unknown>): string[] {
|
function extractImagesFromGenerationsResponse(data: Record<string, unknown>): string[] {
|
||||||
const images: 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)) {
|
if (Array.isArray(data.data)) {
|
||||||
for (const item of data.data as Array<Record<string, unknown>>) {
|
for (const item of data.data as Array<Record<string, unknown>>) {
|
||||||
if (typeof item === 'string') { images.push(item); continue; }
|
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') {
|
} else if (typeof data.image_url === 'string') {
|
||||||
images.push(data.image_url);
|
images.push(data.image_url);
|
||||||
}
|
}
|
||||||
|
visit(data);
|
||||||
|
|
||||||
const streamEvents = data.__streamEvents;
|
const streamEvents = data.__streamEvents;
|
||||||
if (Array.isArray(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 */
|
/** Track which strategy produced a result */
|
||||||
@@ -454,7 +479,7 @@ async function tryImageStrategy(
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
},
|
||||||
GENERATION_TIMEOUT,
|
GENERATION_TIMEOUT,
|
||||||
1,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -557,7 +582,7 @@ async function tryEditsWithFormData(
|
|||||||
body: bodyBuffer,
|
body: bodyBuffer,
|
||||||
},
|
},
|
||||||
GENERATION_TIMEOUT,
|
GENERATION_TIMEOUT,
|
||||||
1,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
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 { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||||
import { downloadFile } from '@/lib/utils';
|
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 { useSiteConfig } from '@/lib/site-config';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -355,7 +355,7 @@ export function ImageToImagePanel() {
|
|||||||
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
||||||
'image',
|
'image',
|
||||||
requestBody,
|
requestBody,
|
||||||
{ timeoutMs: 300_000, onStatus: setGenerationJobStatus },
|
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
|
||||||
);
|
);
|
||||||
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
||||||
if (data.images && data.images.length > 0) {
|
if (data.images && data.images.length > 0) {
|
||||||
@@ -387,9 +387,12 @@ export function ImageToImagePanel() {
|
|||||||
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
if (err instanceof GenerationJobStillRunningError) {
|
||||||
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
setGenerationError(null);
|
||||||
} else {
|
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
|
||||||
|
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
||||||
|
} else {
|
||||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
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 { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||||
import { downloadFile } from '@/lib/utils';
|
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 { useSiteConfig } from '@/lib/site-config';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -227,7 +227,7 @@ export function TextToImagePanel() {
|
|||||||
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
||||||
'image',
|
'image',
|
||||||
requestBody,
|
requestBody,
|
||||||
{ timeoutMs: 300_000, onStatus: setGenerationJobStatus },
|
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
|
||||||
);
|
);
|
||||||
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
||||||
if (data.images && data.images.length > 0) {
|
if (data.images && data.images.length > 0) {
|
||||||
@@ -258,7 +258,10 @@ export function TextToImagePanel() {
|
|||||||
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} 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('请求超时,请尝试减少生成数量或降低分辨率'));
|
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
||||||
} else {
|
} else {
|
||||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||||
|
|||||||
@@ -226,31 +226,42 @@ export async function parseCustomApiJsonWithProgress(
|
|||||||
const streamEvents: unknown[] = [];
|
const streamEvents: unknown[] = [];
|
||||||
let streamText = '';
|
let streamText = '';
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const { value, done } = await reader.read();
|
while (true) {
|
||||||
if (value) buffer += decoder.decode(value, { stream: !done });
|
const { value, done } = await reader.read();
|
||||||
const lines = buffer.split(/\r?\n/);
|
if (value) buffer += decoder.decode(value, { stream: !done });
|
||||||
buffer = done ? '' : lines.pop() || '';
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = done ? '' : lines.pop() || '';
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
if (!line || line === 'data: [DONE]' || line === '[DONE]') continue;
|
if (!line || line === 'data: [DONE]' || line === '[DONE]') continue;
|
||||||
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
if (line.startsWith('event:')) {
|
||||||
if (!payload || payload === '[DONE]') continue;
|
const eventName = line.slice(6).trim();
|
||||||
try {
|
const progress = extractUpstreamProgress({ message: `event: ${eventName}` });
|
||||||
const parsed = JSON.parse(payload);
|
if (progress) await onProgress?.(progress);
|
||||||
lastJson = parsed;
|
continue;
|
||||||
streamEvents.push(parsed);
|
}
|
||||||
streamText += extractStreamingTextDelta(parsed);
|
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
||||||
const progress = extractUpstreamProgress(parsed);
|
if (!payload || payload === '[DONE]') continue;
|
||||||
if (progress) await onProgress?.(progress);
|
try {
|
||||||
} catch {
|
const parsed = JSON.parse(payload);
|
||||||
const progress = extractUpstreamProgress({ message: payload });
|
lastJson = parsed;
|
||||||
if (progress) await onProgress?.(progress);
|
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()) {
|
if (buffer.trim()) {
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ type GenerationJobOptions = {
|
|||||||
onStatus?: (status: GenerationJobStatus) => void;
|
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 {
|
function getAuthToken(): string | null {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem('miaojing_auth');
|
const raw = window.localStorage.getItem('miaojing_auth');
|
||||||
@@ -76,9 +86,10 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
|||||||
status: 'queued',
|
status: 'queued',
|
||||||
} as GenerationJobStatus);
|
} 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 intervalMs = options.intervalMs ?? 2_000;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
let lastStatus: GenerationJobStatus | null = null;
|
||||||
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
await sleep(intervalMs);
|
await sleep(intervalMs);
|
||||||
@@ -93,6 +104,7 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
|||||||
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
||||||
}
|
}
|
||||||
options.onStatus?.(statusData as GenerationJobStatus);
|
options.onStatus?.(statusData as GenerationJobStatus);
|
||||||
|
lastStatus = statusData as GenerationJobStatus;
|
||||||
|
|
||||||
if (statusData.status === 'succeeded') {
|
if (statusData.status === 'succeeded') {
|
||||||
return (statusData.result || {}) as T;
|
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