2026-06-03 23:20:57 +08:00
|
|
|
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
|
|
|
|
import { aiGenerationClient } from "../api/aiGenerationClient";
|
2026-06-05 01:00:33 +08:00
|
|
|
import {
|
|
|
|
|
buildLocalTimeoutMessage,
|
|
|
|
|
buildTaskFailureInfo,
|
|
|
|
|
getTaskTimeoutPolicy,
|
|
|
|
|
isTaskLocallyTimedOut,
|
|
|
|
|
} from "../utils/taskLifecycle";
|
2026-06-03 23:20:57 +08:00
|
|
|
|
|
|
|
|
type PollCallback = (item: GenerationQueueItem) => void;
|
|
|
|
|
|
|
|
|
|
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
|
|
|
|
|
const pollCallbacks = new Set<PollCallback>();
|
|
|
|
|
|
|
|
|
|
const POLL_INTERVAL = 3000;
|
2026-06-05 01:00:33 +08:00
|
|
|
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
|
2026-06-03 23:20:57 +08:00
|
|
|
|
|
|
|
|
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
|
|
|
|
pollCallbacks.add(callback);
|
|
|
|
|
return () => { pollCallbacks.delete(callback); };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function notifyCallbacks(item: GenerationQueueItem): void {
|
|
|
|
|
pollCallbacks.forEach((cb) => cb(item));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 01:00:33 +08:00
|
|
|
function getQueueItemKind(item: GenerationQueueItem): "image" | "video" | "text" {
|
|
|
|
|
if (item.type === "image") return "image";
|
|
|
|
|
if (item.type === "video" || item.type === "ecommerce-video") return "video";
|
|
|
|
|
return "text";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
|
|
|
|
|
return typeof item.params?.model === "string" ? item.params.model : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:20:57 +08:00
|
|
|
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
|
|
|
|
|
const key = `poll-${item.id}`;
|
|
|
|
|
if (activePollers.has(key)) return;
|
|
|
|
|
|
2026-06-05 01:00:33 +08:00
|
|
|
const kind = getQueueItemKind(item);
|
|
|
|
|
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
|
|
|
|
|
let lastProgress = Math.max(0, Number(item.progress || 0));
|
|
|
|
|
let lastProgressAt = Date.now();
|
|
|
|
|
|
2026-06-03 23:20:57 +08:00
|
|
|
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++;
|
2026-06-05 01:00:33 +08:00
|
|
|
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);
|
2026-06-03 23:20:57 +08:00
|
|
|
useGenerationStore.getState().updateTask(item.id, {
|
|
|
|
|
status: "failed",
|
2026-06-05 01:00:33 +08:00
|
|
|
error,
|
2026-06-03 23:20:57 +08:00
|
|
|
});
|
2026-06-05 01:00:33 +08:00
|
|
|
notifyCallbacks({ ...item, status: "failed", error });
|
2026-06-03 23:20:57 +08:00
|
|
|
cleanupPoll(key);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
|
2026-06-05 01:00:33 +08:00
|
|
|
const nextProgress = Number(status.progress || 0);
|
|
|
|
|
if (nextProgress > lastProgress || status.status === "completed") {
|
|
|
|
|
lastProgress = Math.max(lastProgress, nextProgress);
|
|
|
|
|
lastProgressAt = Date.now();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:20:57 +08:00
|
|
|
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";
|
2026-06-05 01:00:33 +08:00
|
|
|
patch.error = buildTaskFailureInfo(status.error).message;
|
2026-06-03 23:20:57 +08:00
|
|
|
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 {
|
2026-06-05 01:00:33 +08:00
|
|
|
// Network errors during polling are retried until the lifecycle guard trips.
|
2026-06-03 23:20:57 +08:00
|
|
|
}
|
|
|
|
|
}, POLL_INTERVAL);
|
|
|
|
|
|
|
|
|
|
activePollers.set(key, interval);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanupPoll(key: string): void {
|
|
|
|
|
const interval = activePollers.get(key);
|
|
|
|
|
if (interval) {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
activePollers.delete(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function startBackgroundPolling(): void {
|
|
|
|
|
const tasks = useGenerationStore.getState().getRunningTasks();
|
|
|
|
|
const attemptsMap = new Map<string, { current: number }>();
|
|
|
|
|
|
|
|
|
|
tasks.forEach((task) => {
|
|
|
|
|
if (task.taskId) {
|
|
|
|
|
if (!attemptsMap.has(task.id)) {
|
|
|
|
|
attemptsMap.set(task.id, { current: 0 });
|
|
|
|
|
}
|
|
|
|
|
pollTask(task, attemptsMap.get(task.id)!);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resumeTaskPolling(taskId: string, storeId: string): void {
|
|
|
|
|
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
|
|
|
|
|
if (task && task.status !== "completed" && task.status !== "failed") {
|
|
|
|
|
pollTask(task, { current: 0 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stopAllPolling(): void {
|
|
|
|
|
activePollers.forEach((interval) => clearInterval(interval));
|
|
|
|
|
activePollers.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function recoverAndResumeTasks(): void {
|
|
|
|
|
const pendingTasks = useGenerationStore.getState().getRunningTasks();
|
|
|
|
|
if (!pendingTasks.length) return;
|
|
|
|
|
|
|
|
|
|
pendingTasks.forEach((task) => {
|
|
|
|
|
if (task.taskId) {
|
|
|
|
|
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
|
|
|
|
|
} else {
|
|
|
|
|
useGenerationStore.getState().updateTask(task.id, {
|
|
|
|
|
status: "failed",
|
2026-06-05 01:00:33 +08:00
|
|
|
error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交。",
|
2026-06-03 23:20:57 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setTimeout(() => startBackgroundPolling(), 500);
|
|
|
|
|
}
|