Codex/generation task reliability #20

Merged
stringadmin merged 20 commits from codex/generation-task-reliability into master 2026-06-08 05:56:38 +00:00
11 changed files with 256 additions and 278 deletions
Showing only changes of commit 9999e516ae - Show all commits
+2 -2
View File
@@ -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,
+80 -101
View File
@@ -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 {
method: "PATCH", await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
headers: buildAuthHeaders(), method: "PATCH",
}); maxRetries: NON_RETRYING_REQUEST.maxRetries,
if (!res.ok && res.status !== 404) { fallbackMessage: "Task cancel failed",
await throwResponseError(res, "Task cancel failed"); });
} catch (error) {
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,49 +377,41 @@ 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}` : ""}`), { try {
method: "GET", const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
headers: buildAuthHeaders(), fallbackMessage: "Task history request failed",
}); });
if (!res.ok) { return extractTaskList(payload).map(toPreviewTask);
try { } catch (error) {
await throwResponseError(res, "Task history request failed"); if (isOptionalApiRouteMissing(error)) {
} catch (error) { taskHistoryRouteMissing = true;
if (isOptionalApiRouteMissing(error)) { return [];
taskHistoryRouteMissing = true;
return [];
}
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 {
method: "PATCH", await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
headers: buildAuthHeaders(), method: "PATCH",
body: JSON.stringify({ conversationId }), body: { conversationId },
}); maxRetries: NON_RETRYING_REQUEST.maxRetries,
if (res.status === 404) { fallbackMessage: "Task conversation binding failed",
return; });
} } catch (error) {
if (!res.ok) { if (isOptionalApiRouteMissing(error)) return;
await throwResponseError(res, "Task conversation binding failed"); throw error;
} }
}, },
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(
+4 -9
View File
@@ -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
View File
@@ -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("模型未返回有效内容");
+8 -3
View File
@@ -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;
} }
+37 -4
View File
@@ -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) =>
+12 -23
View File
@@ -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),
}); });
@@ -559,7 +561,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls, referenceUrls: refUrls,
}); });
taskIdRef.current = taskId; taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 }); saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId); const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) { if (tempUrl) {
+1 -1
View File
@@ -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);
+11 -28
View File
@@ -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(); try {
const { aiGenerationClient } = await import("../../api/aiGenerationClient"); return await waitForTask(taskId, {
kind,
while (true) { abortRef,
if (abortRef?.current) return null; onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))),
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null; });
} catch {
try { return null;
const task = await aiGenerationClient.getTaskStatus(taskId);
if (!task) return null;
const progress = Math.min(99, task.progress || 0);
onProgress?.(progress);
if (task.status === "completed") {
return task.resultUrl || null;
}
if (task.status === "failed" || task.status === "cancelled") {
return null;
}
} catch {
// retry on next poll
}
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
} }
} }
+87 -95
View File
@@ -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 key = `poll-${item.id}`; const current = useGenerationStore.getState().queue.find((i) => i.id === id);
if (activePollers.has(key)) return; if (!current) return null;
const next = { ...current, ...patch };
const kind = getQueueItemKind(item); useGenerationStore.getState().updateTask(id, patch);
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) }); notifyCallbacks(next);
let lastProgress = Math.max(0, Number(item.progress || 0)); return next;
let lastProgressAt = Date.now();
const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
cleanupPoll(key);
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> = {
progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl,
error: status.error || current.error,
};
if (status.status === "completed") {
patch.status = "completed";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "completed" });
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
} else {
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 { function isTerminalStatus(status: GenerationQueueItem["status"]): boolean {
const interval = activePollers.get(key); return status === "completed" || status === "failed" || status === "cancelled";
if (interval) { }
clearInterval(interval);
activePollers.delete(key); function pollTask(item: GenerationQueueItem): void {
} const key = `poll-${item.id}`;
if (activePollers.has(key) || !item.taskId) return;
const kind = getQueueItemKind(item);
const abortRef = { current: false };
activePollers.set(key, abortRef);
const applyProgress = (event: TaskProgressEvent) => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) {
abortRef.current = true;
return;
}
const patch: Partial<GenerationQueueItem> = {
progress: Number(event.progress || 0),
resultUrl: event.resultUrl || current.resultUrl,
error: event.error || current.error,
};
if (event.status === "completed") {
patch.status = "completed";
patch.progress = 100;
} else if (event.status === "failed" || event.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(event.error).message;
} else {
patch.status = "running";
}
updateTaskAndNotify(item.id, patch);
};
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);
} }
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();
} }