fix: improve generation task reliability
This commit is contained in:
@@ -42,9 +42,9 @@ assertNoMatch(
|
|||||||
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
||||||
);
|
);
|
||||||
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
||||||
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
|
assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/);
|
||||||
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
||||||
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
|
assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/);
|
||||||
assertMatch(
|
assertMatch(
|
||||||
"ecommerce video history must durable-copy media before saving",
|
"ecommerce video history must durable-copy media before saving",
|
||||||
ecommerceVideoService,
|
ecommerceVideoService,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildAuthHeaders,
|
buildAuthHeaders,
|
||||||
isRecord,
|
isRecord,
|
||||||
readJsonResponse,
|
readJsonResponse,
|
||||||
|
serverRequest,
|
||||||
throwResponseError,
|
throwResponseError,
|
||||||
} from "./serverConnection";
|
} from "./serverConnection";
|
||||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||||
@@ -243,6 +244,10 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
|
|||||||
|
|
||||||
let taskHistoryRouteMissing = false;
|
let taskHistoryRouteMissing = false;
|
||||||
|
|
||||||
|
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
|
||||||
|
const TASK_STATUS_TIMEOUT_MS = 20_000;
|
||||||
|
const NON_RETRYING_REQUEST = { maxRetries: 0 };
|
||||||
|
|
||||||
export const aiGenerationClient = {
|
export const aiGenerationClient = {
|
||||||
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
||||||
const requestUrl = buildApiUrl("ai/image");
|
const requestUrl = buildApiUrl("ai/image");
|
||||||
@@ -256,15 +261,13 @@ export const aiGenerationClient = {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
conversationId: input.conversationId,
|
conversationId: input.conversationId,
|
||||||
});
|
});
|
||||||
const res = await fetch(requestUrl, {
|
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Image generation request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Image generation request failed");
|
|
||||||
}
|
|
||||||
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
|
|
||||||
if (payload.providerDebug) {
|
if (payload.providerDebug) {
|
||||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
@@ -272,96 +275,83 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video"), {
|
return serverRequest<{ taskId: string }>("ai/video", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Video generation request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Video generation request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Video generation response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
|
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Video super-resolution request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Video super-resolution request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
|
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Subtitle removal request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Subtitle removal request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
|
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/video/edit"), {
|
return serverRequest<{ taskId: string }>("ai/video/edit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" },
|
||||||
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Video edit request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Video edit request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
|
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Image super-resolution request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Image super-resolution request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
||||||
const res = await fetch(buildApiUrl("ai/image/edit"), {
|
return serverRequest<{ taskId: string }>("ai/image/edit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Image edit request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Image edit request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelTask(taskId: string): Promise<void> {
|
async cancelTask(taskId: string): Promise<void> {
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), {
|
try {
|
||||||
|
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildAuthHeaders(),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Task cancel failed",
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 404) {
|
} catch (error) {
|
||||||
await throwResponseError(res, "Task cancel failed");
|
if (isOptionalApiRouteMissing(error)) return;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
|
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
||||||
method: "GET",
|
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||||
headers: buildAuthHeaders(),
|
fallbackMessage: "Task status request failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Task status request failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<AiTaskStatus>(res, "Task status response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
||||||
@@ -387,13 +377,11 @@ export const aiGenerationClient = {
|
|||||||
if (params?.status) search.set("status", params.status);
|
if (params?.status) search.set("status", params.status);
|
||||||
if (params?.type) search.set("type", params.type);
|
if (params?.type) search.set("type", params.type);
|
||||||
if (params?.projectId) search.set("projectId", params.projectId);
|
if (params?.projectId) search.set("projectId", params.projectId);
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), {
|
|
||||||
method: "GET",
|
|
||||||
headers: buildAuthHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
try {
|
try {
|
||||||
await throwResponseError(res, "Task history request failed");
|
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
||||||
|
fallbackMessage: "Task history request failed",
|
||||||
|
});
|
||||||
|
return extractTaskList(payload).map(toPreviewTask);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isOptionalApiRouteMissing(error)) {
|
if (isOptionalApiRouteMissing(error)) {
|
||||||
taskHistoryRouteMissing = true;
|
taskHistoryRouteMissing = true;
|
||||||
@@ -401,35 +389,29 @@ export const aiGenerationClient = {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
|
|
||||||
return extractTaskList(payload).map(toPreviewTask);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
||||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), {
|
try {
|
||||||
|
await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildAuthHeaders(),
|
body: { conversationId },
|
||||||
body: JSON.stringify({ conversationId }),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Task conversation binding failed",
|
||||||
});
|
});
|
||||||
if (res.status === 404) {
|
} catch (error) {
|
||||||
return;
|
if (isOptionalApiRouteMissing(error)) return;
|
||||||
}
|
throw error;
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Task conversation binding failed");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||||
const res = await fetch(buildApiUrl("oss/upload"), {
|
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Asset upload failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Asset upload failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||||
@@ -451,15 +433,12 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||||
const res = await fetch(buildApiUrl("oss/upload-by-url"), {
|
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: input,
|
||||||
body: JSON.stringify(input),
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
|
fallbackMessage: "Asset upload by URL failed",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
await throwResponseError(res, "Asset upload by URL failed");
|
|
||||||
}
|
|
||||||
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response failed");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeTaskStatus(
|
subscribeTaskStatus(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
import { serverRequest } from "./serverConnection";
|
||||||
|
|
||||||
export interface ProviderHealthEntry {
|
export interface ProviderHealthEntry {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -32,13 +32,8 @@ export interface ProviderHealthResponse {
|
|||||||
|
|
||||||
export const providerHealthClient = {
|
export const providerHealthClient = {
|
||||||
async getStatus(): Promise<ProviderHealthResponse> {
|
async getStatus(): Promise<ProviderHealthResponse> {
|
||||||
const res = await fetch(buildApiUrl("admin/providers/status"), {
|
return serverRequest<ProviderHealthResponse>("admin/providers/status", {
|
||||||
method: "GET",
|
fallbackMessage: "Provider health request failed",
|
||||||
headers: buildAuthHeaders(),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Provider health request failed (${res.status})`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<ProviderHealthResponse>;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+11
-11
@@ -1,4 +1,4 @@
|
|||||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
import { serverRequest } from "./serverConnection";
|
||||||
|
|
||||||
export interface ScriptEvalResult {
|
export interface ScriptEvalResult {
|
||||||
totalScore: number;
|
totalScore: number;
|
||||||
@@ -140,10 +140,13 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const payload = await serverRequest<{
|
||||||
|
content?: string;
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
text?: string;
|
||||||
|
}>("ai/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
body: {
|
||||||
body: JSON.stringify({
|
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
||||||
@@ -153,16 +156,13 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
|||||||
stream: false,
|
stream: false,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
}),
|
},
|
||||||
signal,
|
signal,
|
||||||
|
timeoutMs: 180_000,
|
||||||
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "评测请求失败",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errText = await res.text().catch(() => "");
|
|
||||||
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await res.json();
|
|
||||||
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||||
|
|
||||||
if (!content) throw new Error("模型未返回有效内容");
|
if (!content) throw new Error("模型未返回有效内容");
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface ServerRequestOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
|
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
|
||||||
|
maxRetries?: number;
|
||||||
|
fallbackMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||||
@@ -343,8 +346,10 @@ const MAX_RETRIES = 2;
|
|||||||
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
|
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
|
||||||
|
const fallbackMessage = options?.fallbackMessage || "Request failed";
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
const controller = timeoutMs > 0 ? new AbortController() : null;
|
const controller = timeoutMs > 0 ? new AbortController() : null;
|
||||||
const timeoutId =
|
const timeoutId =
|
||||||
controller && typeof window !== "undefined"
|
controller && typeof window !== "undefined"
|
||||||
@@ -366,11 +371,11 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
|
|||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await readJsonResponse<unknown>(response, "Request failed");
|
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
|
||||||
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
|
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) {
|
||||||
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
|
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
|||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||||
import { communityClient } from "../../api/communityClient";
|
import { communityClient } from "../../api/communityClient";
|
||||||
|
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
import type {
|
import type {
|
||||||
@@ -118,7 +119,7 @@ import {
|
|||||||
defaultVideoModel,
|
defaultVideoModel,
|
||||||
image4kCapableModels,
|
image4kCapableModels,
|
||||||
imageFocusRatioOptions,
|
imageFocusRatioOptions,
|
||||||
imageModelOptions,
|
imageModelOptions as fallbackCanvasImageModelOptions,
|
||||||
imageRatioOptions,
|
imageRatioOptions,
|
||||||
textModelOptions,
|
textModelOptions,
|
||||||
videoDurationOptions,
|
videoDurationOptions,
|
||||||
@@ -354,6 +355,8 @@ function CanvasPage({
|
|||||||
const [projectNameEditing, setProjectNameEditing] = useState(false);
|
const [projectNameEditing, setProjectNameEditing] = useState(false);
|
||||||
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
|
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
|
||||||
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
|
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
|
||||||
|
const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
|
||||||
|
const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
|
||||||
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
|
||||||
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
|
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
|
||||||
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
|
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
|
||||||
@@ -458,9 +461,39 @@ function CanvasPage({
|
|||||||
callbacksRef: dragCallbacksRef,
|
callbacksRef: dragCallbacksRef,
|
||||||
suppressNextPaneClickRef,
|
suppressNextPaneClickRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
|
||||||
|
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
modelCapabilitiesClient
|
||||||
|
.get()
|
||||||
|
.then((capabilities) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
|
||||||
|
setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
|
||||||
|
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const visibleImageModelOptions = useMemo(
|
const visibleImageModelOptions = useMemo(
|
||||||
() => filterImageModelOptionsForSession(imageModelOptions, session),
|
() => filterImageModelOptionsForSession(canvasImageModelOptions, session),
|
||||||
[session],
|
[canvasImageModelOptions, session],
|
||||||
);
|
);
|
||||||
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
|
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
|
||||||
const resolveVisibleImageModel = useCallback(
|
const resolveVisibleImageModel = useCallback(
|
||||||
@@ -5044,7 +5077,7 @@ function CanvasPage({
|
|||||||
ariaLabel="选择视频模型"
|
ariaLabel="选择视频模型"
|
||||||
className="canvas-select-chip--model studio-canvas-composer-chip"
|
className="canvas-select-chip--model studio-canvas-composer-chip"
|
||||||
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
|
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
|
||||||
options={canvasEnterpriseVideoModelOptions}
|
options={canvasVideoModelOptions}
|
||||||
open={canvasSelectMenu === `${videoNode.id}:video-model`}
|
open={canvasSelectMenu === `${videoNode.id}:video-model`}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setCanvasSelectMenu((current) =>
|
setCanvasSelectMenu((current) =>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type AdVideoUserConfig,
|
type AdVideoUserConfig,
|
||||||
} from "../../api/adVideoPlanClient";
|
} from "../../api/adVideoPlanClient";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
|
import { serverRequest } from "../../api/serverConnection";
|
||||||
import { waitForTask } from "../../api/taskSubscription";
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||||
@@ -430,15 +431,6 @@ export interface VideoHistoryListResponse {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { getStoredToken } from "../../api/serverConnection";
|
|
||||||
|
|
||||||
const API_BASE = "/api/ai/ecommerce/video-history";
|
|
||||||
|
|
||||||
function getAuthHeaders(): Record<string, string> {
|
|
||||||
const token = getStoredToken();
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||||
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||||
const scenes = await Promise.all(
|
const scenes = await Promise.all(
|
||||||
@@ -486,13 +478,12 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
|
|||||||
|
|
||||||
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
||||||
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
||||||
const res = await fetch(API_BASE, {
|
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
body: historyPayload,
|
||||||
body: JSON.stringify(historyPayload),
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "Failed to save video history",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save video history");
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
||||||
@@ -511,12 +502,10 @@ export async function fetchVideoHistory(
|
|||||||
limit = 20,
|
limit = 20,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
): Promise<VideoHistoryListResponse> {
|
): Promise<VideoHistoryListResponse> {
|
||||||
const res = await fetch(
|
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
|
||||||
{ headers: getAuthHeaders() },
|
fallbackMessage: "Failed to fetch video history",
|
||||||
);
|
});
|
||||||
if (!res.ok) throw new Error("Failed to fetch video history");
|
|
||||||
const history = (await res.json()) as VideoHistoryListResponse;
|
|
||||||
return {
|
return {
|
||||||
...history,
|
...history,
|
||||||
items: history.items.map(removeTemporaryHistoryUrls),
|
items: history.items.map(removeTemporaryHistoryUrls),
|
||||||
@@ -524,9 +513,9 @@ export async function fetchVideoHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/${id}`, {
|
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getAuthHeaders(),
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "Failed to delete video history",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to delete video history");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
abortRef.current = false;
|
abortRef.current = false;
|
||||||
taskIdRef.current = saved.taskId;
|
taskIdRef.current = saved.taskId;
|
||||||
void waitForTask(saved.taskId, {
|
void waitForTask(saved.taskId, {
|
||||||
|
kind: "image",
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
setStatus(`${e.status} / ${e.progress}%`);
|
setStatus(`${e.status} / ${e.progress}%`);
|
||||||
if (e.status === "completed" && e.resultUrl) {
|
if (e.status === "completed" && e.resultUrl) {
|
||||||
@@ -446,6 +447,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
|
|
||||||
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
|
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
|
||||||
return waitForTask(taskId, {
|
return waitForTask(taskId, {
|
||||||
|
kind: "image",
|
||||||
abortRef,
|
abortRef,
|
||||||
onProgress: (e) => setGenerationProgress(e.progress || 0),
|
onProgress: (e) => setGenerationProgress(e.progress || 0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ function WorkbenchPage({
|
|||||||
.get()
|
.get()
|
||||||
.then((capabilities) => {
|
.then((capabilities) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const nextVideoModels = VIDEO_MODEL_OPTIONS;
|
const nextVideoModels = capabilities.videoModels.length ? capabilities.videoModels : VIDEO_MODEL_OPTIONS;
|
||||||
|
|
||||||
applyImageModels(capabilities.imageModels);
|
applyImageModels(capabilities.imageModels);
|
||||||
setVideoModelOptions(nextVideoModels);
|
setVideoModelOptions(nextVideoModels);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Persists task state to localStorage so in-progress tasks survive page switches.
|
* Persists task state to localStorage so in-progress tasks survive page switches.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
|
|
||||||
const KEEPALIVE_PREFIX = "omniai:tool-task:";
|
const KEEPALIVE_PREFIX = "omniai:tool-task:";
|
||||||
|
|
||||||
interface ToolTaskKeepalive {
|
interface ToolTaskKeepalive {
|
||||||
@@ -59,38 +61,19 @@ export function clearToolTaskState(key: string): void {
|
|||||||
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
|
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const TASK_POLL_INTERVAL = 3000;
|
|
||||||
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
export async function pollTaskUntilDone(
|
export async function pollTaskUntilDone(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
onProgress?: (progress: number) => void,
|
onProgress?: (progress: number) => void,
|
||||||
abortRef?: { current: boolean },
|
abortRef?: { current: boolean },
|
||||||
|
kind: "image" | "video" = "video",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const startTime = Date.now();
|
|
||||||
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (abortRef?.current) return null;
|
|
||||||
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await aiGenerationClient.getTaskStatus(taskId);
|
return await waitForTask(taskId, {
|
||||||
if (!task) return null;
|
kind,
|
||||||
|
abortRef,
|
||||||
const progress = Math.min(99, task.progress || 0);
|
onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))),
|
||||||
onProgress?.(progress);
|
});
|
||||||
|
} catch {
|
||||||
if (task.status === "completed") {
|
|
||||||
return task.resultUrl || null;
|
|
||||||
}
|
|
||||||
if (task.status === "failed" || task.status === "cancelled") {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// retry on next poll
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
||||||
import { aiGenerationClient } from "../api/aiGenerationClient";
|
import { waitForTask, type TaskProgressEvent } from "../api/taskSubscription";
|
||||||
import {
|
import { buildTaskFailureInfo } from "../utils/taskLifecycle";
|
||||||
buildLocalTimeoutMessage,
|
|
||||||
buildTaskFailureInfo,
|
|
||||||
getTaskTimeoutPolicy,
|
|
||||||
isTaskLocallyTimedOut,
|
|
||||||
} from "../utils/taskLifecycle";
|
|
||||||
|
|
||||||
type PollCallback = (item: GenerationQueueItem) => void;
|
type PollCallback = (item: GenerationQueueItem) => void;
|
||||||
|
|
||||||
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
|
const activePollers = new Map<string, { current: boolean }>();
|
||||||
const pollCallbacks = new Set<PollCallback>();
|
const pollCallbacks = new Set<PollCallback>();
|
||||||
|
|
||||||
const POLL_INTERVAL = 3000;
|
|
||||||
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
|
|
||||||
|
|
||||||
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
||||||
pollCallbacks.add(callback);
|
pollCallbacks.add(callback);
|
||||||
return () => { pollCallbacks.delete(callback); };
|
return () => { pollCallbacks.delete(callback); };
|
||||||
@@ -34,109 +26,109 @@ function getQueueItemModel(item: GenerationQueueItem): string | undefined {
|
|||||||
return typeof item.params?.model === "string" ? item.params.model : undefined;
|
return typeof item.params?.model === "string" ? item.params.model : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
|
function updateTaskAndNotify(id: string, patch: Partial<GenerationQueueItem>): GenerationQueueItem | null {
|
||||||
|
const current = useGenerationStore.getState().queue.find((i) => i.id === id);
|
||||||
|
if (!current) return null;
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
useGenerationStore.getState().updateTask(id, patch);
|
||||||
|
notifyCallbacks(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStatus(status: GenerationQueueItem["status"]): boolean {
|
||||||
|
return status === "completed" || status === "failed" || status === "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollTask(item: GenerationQueueItem): void {
|
||||||
const key = `poll-${item.id}`;
|
const key = `poll-${item.id}`;
|
||||||
if (activePollers.has(key)) return;
|
if (activePollers.has(key) || !item.taskId) return;
|
||||||
|
|
||||||
const kind = getQueueItemKind(item);
|
const kind = getQueueItemKind(item);
|
||||||
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
|
const abortRef = { current: false };
|
||||||
let lastProgress = Math.max(0, Number(item.progress || 0));
|
activePollers.set(key, abortRef);
|
||||||
let lastProgressAt = Date.now();
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const applyProgress = (event: TaskProgressEvent) => {
|
||||||
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||||
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
|
if (!current || isTerminalStatus(current.status)) {
|
||||||
cleanupPoll(key);
|
abortRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptsRef.current++;
|
|
||||||
const timeoutReason = isTaskLocallyTimedOut({
|
|
||||||
startedAt: current.createdAt || item.createdAt || Date.now(),
|
|
||||||
lastProgressAt,
|
|
||||||
progress: lastProgress,
|
|
||||||
policy: timeoutPolicy,
|
|
||||||
});
|
|
||||||
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
|
|
||||||
const error = buildLocalTimeoutMessage(kind);
|
|
||||||
useGenerationStore.getState().updateTask(item.id, {
|
|
||||||
status: "failed",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
notifyCallbacks({ ...item, status: "failed", error });
|
|
||||||
cleanupPoll(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
|
|
||||||
const nextProgress = Number(status.progress || 0);
|
|
||||||
if (nextProgress > lastProgress || status.status === "completed") {
|
|
||||||
lastProgress = Math.max(lastProgress, nextProgress);
|
|
||||||
lastProgressAt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const patch: Partial<GenerationQueueItem> = {
|
const patch: Partial<GenerationQueueItem> = {
|
||||||
progress: status.progress,
|
progress: Number(event.progress || 0),
|
||||||
resultUrl: status.resultUrl || current.resultUrl,
|
resultUrl: event.resultUrl || current.resultUrl,
|
||||||
error: status.error || current.error,
|
error: event.error || current.error,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status.status === "completed") {
|
if (event.status === "completed") {
|
||||||
patch.status = "completed";
|
patch.status = "completed";
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
patch.progress = 100;
|
||||||
notifyCallbacks({ ...item, ...patch, status: "completed" });
|
} else if (event.status === "failed" || event.status === "cancelled") {
|
||||||
cleanupPoll(key);
|
|
||||||
} else if (status.status === "failed" || status.status === "cancelled") {
|
|
||||||
patch.status = "failed";
|
patch.status = "failed";
|
||||||
patch.error = buildTaskFailureInfo(status.error).message;
|
patch.error = buildTaskFailureInfo(event.error).message;
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
|
||||||
notifyCallbacks({ ...item, ...patch, status: "failed" });
|
|
||||||
cleanupPoll(key);
|
|
||||||
} else {
|
} else {
|
||||||
patch.status = "running";
|
patch.status = "running";
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
|
||||||
notifyCallbacks({ ...item, ...patch, status: "running" });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Network errors during polling are retried until the lifecycle guard trips.
|
|
||||||
}
|
|
||||||
}, POLL_INTERVAL);
|
|
||||||
|
|
||||||
activePollers.set(key, interval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupPoll(key: string): void {
|
updateTaskAndNotify(item.id, patch);
|
||||||
const interval = activePollers.get(key);
|
};
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
void waitForTask(item.taskId, {
|
||||||
|
kind,
|
||||||
|
model: getQueueItemModel(item),
|
||||||
|
startedAt: item.createdAt || Date.now(),
|
||||||
|
abortRef,
|
||||||
|
onProgress: applyProgress,
|
||||||
|
})
|
||||||
|
.then((resultUrl) => {
|
||||||
|
if (abortRef.current) return;
|
||||||
|
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||||
|
if (!current || isTerminalStatus(current.status)) return;
|
||||||
|
updateTaskAndNotify(item.id, {
|
||||||
|
status: "completed",
|
||||||
|
progress: 100,
|
||||||
|
resultUrl: resultUrl || current.resultUrl,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (abortRef.current) return;
|
||||||
|
const failure = buildTaskFailureInfo(error instanceof Error ? error.message : String(error));
|
||||||
|
updateTaskAndNotify(item.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: failure.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
cleanupPoll(key, abortRef);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPoll(key: string, abortRef: { current: boolean }): void {
|
||||||
|
if (activePollers.get(key) !== abortRef) return;
|
||||||
activePollers.delete(key);
|
activePollers.delete(key);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function startBackgroundPolling(): void {
|
export function startBackgroundPolling(): void {
|
||||||
const tasks = useGenerationStore.getState().getRunningTasks();
|
const tasks = useGenerationStore.getState().getRunningTasks();
|
||||||
const attemptsMap = new Map<string, { current: number }>();
|
|
||||||
|
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
if (task.taskId) {
|
if (task.taskId) {
|
||||||
if (!attemptsMap.has(task.id)) {
|
pollTask(task);
|
||||||
attemptsMap.set(task.id, { current: 0 });
|
|
||||||
}
|
|
||||||
pollTask(task, attemptsMap.get(task.id)!);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resumeTaskPolling(taskId: string, storeId: string): void {
|
export function resumeTaskPolling(taskId: string, storeId: string): void {
|
||||||
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
|
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
|
||||||
if (task && task.status !== "completed" && task.status !== "failed") {
|
if (task && !isTerminalStatus(task.status)) {
|
||||||
pollTask(task, { current: 0 });
|
pollTask({ ...task, taskId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAllPolling(): void {
|
export function stopAllPolling(): void {
|
||||||
activePollers.forEach((interval) => clearInterval(interval));
|
activePollers.forEach((abortRef) => {
|
||||||
|
abortRef.current = true;
|
||||||
|
});
|
||||||
activePollers.clear();
|
activePollers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user