fix: harden generation task recovery
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
isRecord,
|
isRecord,
|
||||||
readJsonResponse,
|
readJsonResponse,
|
||||||
serverRequest,
|
serverRequest,
|
||||||
|
isServerRequestError,
|
||||||
throwResponseError,
|
throwResponseError,
|
||||||
} from "./serverConnection";
|
} from "./serverConnection";
|
||||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||||
@@ -247,6 +248,46 @@ let taskHistoryRouteMissing = false;
|
|||||||
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
|
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
|
||||||
const TASK_STATUS_TIMEOUT_MS = 20_000;
|
const TASK_STATUS_TIMEOUT_MS = 20_000;
|
||||||
const NON_RETRYING_REQUEST = { maxRetries: 0 };
|
const NON_RETRYING_REQUEST = { maxRetries: 0 };
|
||||||
|
const PENDING_CANCEL_TASKS_KEY = "omniai:pending-task-cancellations";
|
||||||
|
|
||||||
|
function readPendingCancelTaskIds(): string[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PENDING_CANCEL_TASKS_KEY);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePendingCancelTaskIds(taskIds: string[]): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const uniqueIds = Array.from(new Set(taskIds.filter(Boolean)));
|
||||||
|
if (uniqueIds.length) {
|
||||||
|
window.localStorage.setItem(PENDING_CANCEL_TASKS_KEY, JSON.stringify(uniqueIds));
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(PENDING_CANCEL_TASKS_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pending cancellation recovery is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTaskCancelPending(taskId: string): void {
|
||||||
|
writePendingCancelTaskIds([...readPendingCancelTaskIds(), taskId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingTaskCancel(taskId: string): void {
|
||||||
|
writePendingCancelTaskIds(readPendingCancelTaskIds().filter((id) => id !== taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryTaskCancel(error: unknown): boolean {
|
||||||
|
if (!isServerRequestError(error)) return true;
|
||||||
|
const status = error.status;
|
||||||
|
return status === 429 || status === undefined || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
export const aiGenerationClient = {
|
export const aiGenerationClient = {
|
||||||
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
||||||
@@ -335,18 +376,48 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async cancelTask(taskId: string): Promise<void> {
|
async cancelTask(taskId: string): Promise<void> {
|
||||||
|
markTaskCancelPending(taskId);
|
||||||
try {
|
try {
|
||||||
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
|
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Task cancel failed",
|
fallbackMessage: "Task cancel failed",
|
||||||
});
|
});
|
||||||
|
clearPendingTaskCancel(taskId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isOptionalApiRouteMissing(error)) return;
|
if (isOptionalApiRouteMissing(error) || !shouldRetryTaskCancel(error)) {
|
||||||
|
clearPendingTaskCancel(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelTaskOnUnload(taskId: string): void {
|
||||||
|
markTaskCancelPending(taskId);
|
||||||
|
const url = buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/cancel`);
|
||||||
|
const headers = buildAuthHeaders();
|
||||||
|
const body = JSON.stringify({ reason: "page_unload" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
void fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
credentials: "include",
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Page unload cancellation is best-effort.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
flushPendingTaskCancellations(): void {
|
||||||
|
readPendingCancelTaskIds().forEach((taskId) => {
|
||||||
|
this.cancelTask(taskId).catch(() => {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||||
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
||||||
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ function getEffectiveLimit(): number {
|
|||||||
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
|
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEffectiveGenerationLimit(): number {
|
||||||
|
return getEffectiveLimit();
|
||||||
|
}
|
||||||
|
|
||||||
export function getGenerationUserKey(userId?: string | number | null): string {
|
export function getGenerationUserKey(userId?: string | number | null): string {
|
||||||
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,6 @@ function CanvasPage({
|
|||||||
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
|
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageNodeInputRef = useRef<HTMLInputElement>(null);
|
const imageNodeInputRef = useRef<HTMLInputElement>(null);
|
||||||
const canvasRef = useRef<HTMLElement>(null);
|
const canvasRef = useRef<HTMLElement>(null);
|
||||||
const videoGenerationInFlightRef = useRef(new Set<string>());
|
|
||||||
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
|
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
|
||||||
const canvasDragCounterRef = useRef(0);
|
const canvasDragCounterRef = useRef(0);
|
||||||
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||||
@@ -417,7 +416,7 @@ function CanvasPage({
|
|||||||
const {
|
const {
|
||||||
textGenerationState, imageGenerationState, videoGenerationState,
|
textGenerationState, imageGenerationState, videoGenerationState,
|
||||||
generationToast, setGenerationToast,
|
generationToast, setGenerationToast,
|
||||||
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
|
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
|
||||||
canvasGenKeepaliveRestoredRef,
|
canvasGenKeepaliveRestoredRef,
|
||||||
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
|
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
|
||||||
restoreKeepaliveTasks, resetGenerationState,
|
restoreKeepaliveTasks, resetGenerationState,
|
||||||
@@ -1887,13 +1886,14 @@ function CanvasPage({
|
|||||||
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
|
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
|
||||||
setGenerationToast("视频正在生成");
|
setGenerationToast("视频正在生成");
|
||||||
|
|
||||||
|
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||||
try {
|
try {
|
||||||
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
|
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
|
||||||
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
||||||
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
||||||
}
|
}
|
||||||
let requestModel = resolveVideoRequestModel({ model, referenceUrls });
|
let requestModel = resolveVideoRequestModel({ model, referenceUrls });
|
||||||
const task = await onCreateTask({
|
task = await onCreateTask({
|
||||||
title: videoNode.title || "视频节点生成",
|
title: videoNode.title || "视频节点生成",
|
||||||
type: "video",
|
type: "video",
|
||||||
prompt: prompt || "根据参考图片生成视频",
|
prompt: prompt || "根据参考图片生成视频",
|
||||||
@@ -1916,10 +1916,12 @@ function CanvasPage({
|
|||||||
if (task.status === "completed" && !task.outputUrl) {
|
if (task.status === "completed" && !task.outputUrl) {
|
||||||
throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||||
}
|
}
|
||||||
|
const taskId = task.id;
|
||||||
|
addCanvasGenKeepalive(taskId, nodeId, "video", projectId || "");
|
||||||
setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
|
setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
|
||||||
const outputUrl =
|
const outputUrl =
|
||||||
task.outputUrl ||
|
task.outputUrl ||
|
||||||
(await waitForImageTaskResult(task.id, (status) => {
|
(await waitForVideoTaskResult(taskId, (status) => {
|
||||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
status.status === "pending"
|
status.status === "pending"
|
||||||
@@ -1932,11 +1934,12 @@ function CanvasPage({
|
|||||||
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||||
}));
|
}));
|
||||||
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
|
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
|
||||||
|
removeCanvasGenKeepalive(taskId);
|
||||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||||
url: outputUrl,
|
url: outputUrl,
|
||||||
mediaType: "video/mp4",
|
mediaType: "video/mp4",
|
||||||
resultType: "video",
|
resultType: "video",
|
||||||
taskId: task.id,
|
taskId,
|
||||||
originalUrl: outputUrl,
|
originalUrl: outputUrl,
|
||||||
});
|
});
|
||||||
setVideoNodes((currentNodes) =>
|
setVideoNodes((currentNodes) =>
|
||||||
@@ -1947,7 +1950,7 @@ function CanvasPage({
|
|||||||
videoUrl: outputUrl,
|
videoUrl: outputUrl,
|
||||||
assetRef: immediateAssetRef,
|
assetRef: immediateAssetRef,
|
||||||
taskRef: {
|
taskRef: {
|
||||||
taskId: task.id,
|
taskId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
resultUrl: outputUrl,
|
resultUrl: outputUrl,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -1961,7 +1964,7 @@ function CanvasPage({
|
|||||||
url: outputUrl,
|
url: outputUrl,
|
||||||
mediaType: "video/mp4",
|
mediaType: "video/mp4",
|
||||||
resultType: "video",
|
resultType: "video",
|
||||||
taskId: task.id,
|
taskId,
|
||||||
originalUrl: outputUrl,
|
originalUrl: outputUrl,
|
||||||
});
|
});
|
||||||
await delay(420);
|
await delay(420);
|
||||||
@@ -1974,7 +1977,7 @@ function CanvasPage({
|
|||||||
videoUrl: assetRef.url,
|
videoUrl: assetRef.url,
|
||||||
assetRef,
|
assetRef,
|
||||||
taskRef: {
|
taskRef: {
|
||||||
taskId: task.id,
|
taskId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
resultUrl: assetRef.url,
|
resultUrl: assetRef.url,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -1991,6 +1994,7 @@ function CanvasPage({
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
videoGenerationInFlightRef.current.delete(nodeId);
|
videoGenerationInFlightRef.current.delete(nodeId);
|
||||||
|
if (task?.id) removeCanvasGenKeepalive(task.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CanvasVideoGenerationState,
|
CanvasVideoGenerationState,
|
||||||
CanvasVideoNode,
|
CanvasVideoNode,
|
||||||
} from "./canvasTypes";
|
} from "./canvasTypes";
|
||||||
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
|
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
|
||||||
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
|
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
|
||||||
|
|
||||||
@@ -41,6 +42,13 @@ export function removeCanvasGenKeepalive(taskId: string): void {
|
|||||||
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
|
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelCanvasGenKeepaliveOnUnload(): void {
|
||||||
|
const entries = loadCanvasGenKeepalive();
|
||||||
|
if (!entries.length) return;
|
||||||
|
entries.forEach((entry) => aiGenerationClient.cancelTaskOnUnload(entry.taskId));
|
||||||
|
saveCanvasGenKeepalive([]);
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseCanvasGenerationParams {
|
export interface UseCanvasGenerationParams {
|
||||||
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
|
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
|
||||||
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
|
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
|
||||||
@@ -55,6 +63,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
const [generationToast, setGenerationToast] = useState<string | null>(null);
|
const [generationToast, setGenerationToast] = useState<string | null>(null);
|
||||||
|
|
||||||
const imageGenerationInFlightRef = useRef(new Set<string>());
|
const imageGenerationInFlightRef = useRef(new Set<string>());
|
||||||
|
const videoGenerationInFlightRef = useRef(new Set<string>());
|
||||||
const textGenerationInFlightRef = useRef(new Set<string>());
|
const textGenerationInFlightRef = useRef(new Set<string>());
|
||||||
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
|
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
|
||||||
const canvasGenKeepaliveRestoredRef = useRef(false);
|
const canvasGenKeepaliveRestoredRef = useRef(false);
|
||||||
@@ -125,7 +134,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
||||||
});
|
});
|
||||||
} else if (entry.nodeKind === "video") {
|
} else if (entry.nodeKind === "video") {
|
||||||
imageGenerationInFlightRef.current.add(entry.nodeId);
|
videoGenerationInFlightRef.current.add(entry.nodeId);
|
||||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
||||||
void waitForVideoTaskResult(entry.taskId, (status) => {
|
void waitForVideoTaskResult(entry.taskId, (status) => {
|
||||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||||
@@ -154,7 +163,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
removeCanvasGenKeepalive(entry.taskId);
|
removeCanvasGenKeepalive(entry.taskId);
|
||||||
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
videoGenerationInFlightRef.current.delete(entry.nodeId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,11 +174,36 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
textGenerationAbortControllersRef.current.clear();
|
textGenerationAbortControllersRef.current.clear();
|
||||||
textGenerationInFlightRef.current.clear();
|
textGenerationInFlightRef.current.clear();
|
||||||
imageGenerationInFlightRef.current.clear();
|
imageGenerationInFlightRef.current.clear();
|
||||||
|
videoGenerationInFlightRef.current.clear();
|
||||||
setTextGenerationState({});
|
setTextGenerationState({});
|
||||||
setImageGenerationState({});
|
setImageGenerationState({});
|
||||||
setVideoGenerationState({});
|
setVideoGenerationState({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePageHide = () => {
|
||||||
|
cancelCanvasGenKeepaliveOnUnload();
|
||||||
|
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
|
||||||
|
textGenerationAbortControllersRef.current.clear();
|
||||||
|
textGenerationInFlightRef.current.clear();
|
||||||
|
imageGenerationInFlightRef.current.clear();
|
||||||
|
videoGenerationInFlightRef.current.clear();
|
||||||
|
setTextGenerationState({});
|
||||||
|
setImageGenerationState({});
|
||||||
|
setVideoGenerationState({});
|
||||||
|
};
|
||||||
|
const handleOnline = () => {
|
||||||
|
aiGenerationClient.flushPendingTaskCancellations();
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", handlePageHide);
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
aiGenerationClient.flushPendingTaskCancellations();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", handlePageHide);
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
textGenerationState,
|
textGenerationState,
|
||||||
imageGenerationState,
|
imageGenerationState,
|
||||||
@@ -177,6 +211,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
|||||||
generationToast,
|
generationToast,
|
||||||
setGenerationToast,
|
setGenerationToast,
|
||||||
imageGenerationInFlightRef,
|
imageGenerationInFlightRef,
|
||||||
|
videoGenerationInFlightRef,
|
||||||
textGenerationInFlightRef,
|
textGenerationInFlightRef,
|
||||||
textGenerationAbortControllersRef,
|
textGenerationAbortControllersRef,
|
||||||
canvasGenKeepaliveRestoredRef,
|
canvasGenKeepaliveRestoredRef,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
import "../../styles/pages/workbench.css";
|
import "../../styles/pages/workbench.css";
|
||||||
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
|
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
|
import { claimGenerationSlot, getActiveGenerationTaskCount, getEffectiveGenerationLimit, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
|
||||||
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
|
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
|
||||||
import { assetClient } from "../../api/assetClient";
|
import { assetClient } from "../../api/assetClient";
|
||||||
import { communityClient } from "../../api/communityClient";
|
import { communityClient } from "../../api/communityClient";
|
||||||
@@ -1056,6 +1056,54 @@ function WorkbenchPage({
|
|||||||
persistKeepaliveTasks(rest);
|
persistKeepaliveTasks(rest);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const releaseKeepaliveTaskLocally = useCallback((taskId: string, options?: { cancelServer?: boolean }) => {
|
||||||
|
const task = keepaliveTasksRef.current[taskId];
|
||||||
|
taskAbortControllersRef.current.get(taskId)?.abort();
|
||||||
|
taskAbortControllersRef.current.delete(taskId);
|
||||||
|
removeKeepaliveTask(taskId);
|
||||||
|
if (task && options?.cancelServer) {
|
||||||
|
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
|
||||||
|
}
|
||||||
|
syncActiveGenerationUi();
|
||||||
|
}, [syncActiveGenerationUi]);
|
||||||
|
|
||||||
|
const releaseKeepaliveTaskAfterNetworkLoss = useCallback((task: WorkbenchKeepaliveTask, progress: number) => {
|
||||||
|
const latestTask = {
|
||||||
|
...task,
|
||||||
|
progress,
|
||||||
|
statusLabel: "网络中断,已释放提交按钮",
|
||||||
|
};
|
||||||
|
void patchConversationMessage(task.conversationId, task.assistantMessageId, {
|
||||||
|
status: "failed",
|
||||||
|
taskProgress: Math.max(progress, 100),
|
||||||
|
taskStatusLabel: "网络中断",
|
||||||
|
body: "网络中断,当前任务已停止等待并释放提交按钮。请确认网络恢复后重新提交任务。",
|
||||||
|
});
|
||||||
|
upsertKeepaliveTask(latestTask);
|
||||||
|
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
|
||||||
|
if (activeConversationIdRef.current === task.conversationId) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenerationStatus("网络中断,已释放提交按钮");
|
||||||
|
setGenerationProgress(0);
|
||||||
|
}
|
||||||
|
}, [patchConversationMessage, releaseKeepaliveTaskLocally]);
|
||||||
|
|
||||||
|
const cancelActiveKeepaliveTasksOnPageExit = useCallback(() => {
|
||||||
|
const tasks = Object.values(keepaliveTasksRef.current);
|
||||||
|
if (!tasks.length) return;
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
taskAbortControllersRef.current.get(task.taskId)?.abort();
|
||||||
|
taskAbortControllersRef.current.delete(task.taskId);
|
||||||
|
releaseGenerationSlot(task.concurrencySlotId);
|
||||||
|
aiGenerationClient.cancelTaskOnUnload(task.taskId);
|
||||||
|
});
|
||||||
|
keepaliveTasksRef.current = {};
|
||||||
|
persistKeepaliveTasks({});
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenerationStatus("已释放未完成任务");
|
||||||
|
setGenerationProgress(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const runKeepalivePoll = useCallback(
|
const runKeepalivePoll = useCallback(
|
||||||
(task: WorkbenchKeepaliveTask) => {
|
(task: WorkbenchKeepaliveTask) => {
|
||||||
if (taskAbortControllersRef.current.has(task.taskId)) return;
|
if (taskAbortControllersRef.current.has(task.taskId)) return;
|
||||||
@@ -1082,6 +1130,10 @@ function WorkbenchPage({
|
|||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
if (attempt > 0) await sleep(3000);
|
if (attempt > 0) await sleep(3000);
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
|
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
||||||
|
releaseKeepaliveTaskAfterNetworkLoss(task, lastKnownProgress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let status;
|
let status;
|
||||||
try {
|
try {
|
||||||
@@ -1095,7 +1147,8 @@ function WorkbenchPage({
|
|||||||
taskProgress: 100,
|
taskProgress: 100,
|
||||||
taskStatusLabel: "任务异常",
|
taskStatusLabel: "任务异常",
|
||||||
});
|
});
|
||||||
removeKeepaliveTask(task.taskId);
|
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
|
||||||
|
onRefreshUsage?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -1323,6 +1376,24 @@ function WorkbenchPage({
|
|||||||
};
|
};
|
||||||
}, [runKeepalivePoll]);
|
}, [runKeepalivePoll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePageHide = () => {
|
||||||
|
cancelActiveKeepaliveTasksOnPageExit();
|
||||||
|
};
|
||||||
|
const handleOnline = () => {
|
||||||
|
Object.values(keepaliveTasksRef.current).forEach((task) => runKeepalivePoll(task));
|
||||||
|
syncActiveGenerationUi();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", handlePageHide);
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
aiGenerationClient.flushPendingTaskCancellations();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", handlePageHide);
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
};
|
||||||
|
}, [cancelActiveKeepaliveTasksOnPageExit, runKeepalivePoll, syncActiveGenerationUi]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
persistPromptHistory(promptHistory);
|
persistPromptHistory(promptHistory);
|
||||||
}, [promptHistory]);
|
}, [promptHistory]);
|
||||||
@@ -1987,7 +2058,7 @@ function WorkbenchPage({
|
|||||||
const trimmedPrompt = (promptOverride ?? inputValue).trim();
|
const trimmedPrompt = (promptOverride ?? inputValue).trim();
|
||||||
if (!trimmedPrompt) return;
|
if (!trimmedPrompt) return;
|
||||||
const userKey = getGenerationUserKey(session?.user.id);
|
const userKey = getGenerationUserKey(session?.user.id);
|
||||||
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return;
|
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= getEffectiveGenerationLimit()) return;
|
||||||
setReferencePreviewOpen(false);
|
setReferencePreviewOpen(false);
|
||||||
|
|
||||||
let conversationId = activeConversationIdRef.current ?? activeConversationId;
|
let conversationId = activeConversationIdRef.current ?? activeConversationId;
|
||||||
@@ -2466,8 +2537,11 @@ function WorkbenchPage({
|
|||||||
setProjectError("仅支持对视频结果进行超分");
|
setProjectError("仅支持对视频结果进行超分");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
const userKey = getGenerationUserKey(session?.user.id);
|
||||||
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
|
const activeCount = getActiveGenerationTaskCount(userKey);
|
||||||
|
const limit = getEffectiveGenerationLimit();
|
||||||
|
if (activeCount >= limit) {
|
||||||
|
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -2588,8 +2662,11 @@ function WorkbenchPage({
|
|||||||
setProjectError("仅支持对图片结果进行超分");
|
setProjectError("仅支持对图片结果进行超分");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
const userKey = getGenerationUserKey(session?.user.id);
|
||||||
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
|
const activeCount = getActiveGenerationTaskCount(userKey);
|
||||||
|
const limit = getEffectiveGenerationLimit();
|
||||||
|
if (activeCount >= limit) {
|
||||||
|
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -2762,13 +2839,14 @@ function WorkbenchPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
|
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
|
||||||
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
|
const activeGenerationLimit = getEffectiveGenerationLimit();
|
||||||
|
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= activeGenerationLimit;
|
||||||
const promptIsEmpty = !inputValue.trim();
|
const promptIsEmpty = !inputValue.trim();
|
||||||
const sendDisabled = promptIsEmpty || generationLimitReached;
|
const sendDisabled = promptIsEmpty || generationLimitReached;
|
||||||
const sendButtonTitle = promptIsEmpty
|
const sendButtonTitle = promptIsEmpty
|
||||||
? "输入内容后可发送"
|
? "输入内容后可发送"
|
||||||
: generationLimitReached
|
: generationLimitReached
|
||||||
? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
|
? `当前已有 ${activeGenerationCount} 个任务进行中(上限 ${activeGenerationLimit} 个),请等待任一任务完成`
|
||||||
: billingEstimate.title;
|
: billingEstimate.title;
|
||||||
|
|
||||||
const suggestedPrompts = [
|
const suggestedPrompts = [
|
||||||
|
|||||||
Reference in New Issue
Block a user