Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-generation-batches.ts
T

343 lines
20 KiB
TypeScript

import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { generationBatchStatus, isGenerationCostUncertainError } from "@/lib/canvas/canvas-generation-batch";
import { buildGenerationConfig, generationTaskMetadata, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { unchangedModeratedPrompt } from "@/lib/generation-error";
import { cancelGenerationTask, listGenerationTasks } from "@/services/api/task-center";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchMode, CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
const SCHEDULER_INTERVAL_MS = 2_000;
const MAX_BATCH_HISTORY = 20;
type BatchTarget = Pick<CanvasGenerationBatchItem, "rowId" | "nodeId">;
type UseCanvasGenerationBatchesOptions = {
projectId: string;
projectLoaded: boolean;
nodes: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
handleGenerateNode: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise<void>;
};
export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, nodesRef, setNodes, handleGenerateNode }: UseCanvasGenerationBatchesOptions) {
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const activeTaskLimit = useUserStore((state) => state.runtimeLimits.activeTaskLimit);
const schedulingRef = useRef(false);
const controllersRef = useRef(new Map<string, AbortController>());
const updateBatch = useCallback((sourceNodeId: string, batchId: string, updater: (batch: CanvasGenerationBatch) => CanvasGenerationBatch) => {
setNodes((current) => {
let changed = false;
const next = current.map((node) => {
if (node.id !== sourceNodeId || !node.metadata?.generationBatches?.length) return node;
const batches = node.metadata.generationBatches.map((batch) => {
if (batch.id !== batchId) return batch;
const updated = updater(batch);
if (updated !== batch) changed = true;
return updated;
});
return changed ? { ...node, metadata: { ...node.metadata, generationBatches: batches } } : node;
});
return changed ? next : current;
});
}, [setNodes]);
const enqueueGenerationBatch = useCallback((sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: BatchTarget[]) => {
const sourceNode = nodesRef.current.find((node) => node.id === sourceNodeId);
if (!sourceNode || !targets.length) return;
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).flatMap((batch) =>
batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId),
));
const availableTargets = targets.filter((target) => !activeNodeIds.has(target.nodeId));
if (!availableTargets.length) {
message.info("所选镜头已在生成批次中");
return;
}
const now = new Date().toISOString();
const batch: CanvasGenerationBatch = {
id: nanoid(),
projectId,
sourceNodeId,
mode,
status: "queued",
items: availableTargets.map((target) => ({ id: nanoid(), ...target, status: "waiting", retryCount: 0 })),
createdAt: now,
updatedAt: now,
};
setNodes((current) => current.map((node) => node.id === sourceNodeId ? {
...node,
metadata: {
...node.metadata,
generationBatches: [...(node.metadata?.generationBatches || []), batch].slice(-MAX_BATCH_HISTORY),
},
} : node));
return batch.id;
}, [message, nodesRef, projectId, setNodes]);
const reconcileBatches = useCallback(() => {
setNodes((current) => {
const nodeById = new Map(current.map((node) => [node.id, node]));
let changed = false;
const nextNodes = current.map((sourceNode) => {
const batches = sourceNode.metadata?.generationBatches;
if (!batches?.length) return sourceNode;
let sourceChanged = false;
const nextBatches = batches.map((batch) => {
if (batch.projectId !== projectId) return batch;
let batchChanged = false;
const nextItems = batch.items.map((item) => {
if (item.status === "succeeded" || item.status === "failed" || item.status === "cancelled") return item;
const node = nodeById.get(item.nodeId);
let patch: Partial<CanvasGenerationBatchItem> | null = null;
if (!node) {
patch = { status: "failed", errorDetails: "目标节点已不存在" };
} else if (node.metadata?.status === "success" && node.metadata.content) {
patch = { status: "succeeded", taskId: node.metadata.taskId, errorDetails: undefined, costUncertain: false };
} else if (node.metadata?.status === "error") {
const errorDetails = node.metadata.errorDetails || "生成失败";
patch = {
status: node.metadata.taskStatus === "cancelled" ? "cancelled" : "failed",
taskId: node.metadata.taskId,
errorDetails,
costUncertain: isGenerationCostUncertainError(new Error(errorDetails)),
};
} else if (node.metadata?.taskId) {
const taskStatus = node.metadata.taskStatus;
patch = {
taskId: node.metadata.taskId,
// 后端成功后还要下载并写入媒体,节点真正拿到内容才算批次成功。
status: taskStatus === "queued" ? "queued" : taskStatus === "failed" ? "failed" : taskStatus === "cancelled" ? "cancelled" : "running",
errorDetails: undefined,
};
} else if (item.status === "submitting" && !controllersRef.current.has(batchItemKey(batch.id, item.id))) {
patch = { status: "waiting", errorDetails: undefined };
}
if (!patch || !itemChanged(item, patch)) return item;
batchChanged = true;
return { ...item, ...patch };
});
const nextBatch = batchChanged ? { ...batch, items: nextItems } : batch;
const status = generationBatchStatus(nextBatch);
if (!batchChanged && status === batch.status) return batch;
sourceChanged = true;
return { ...nextBatch, status, updatedAt: new Date().toISOString() };
});
if (!sourceChanged) return sourceNode;
changed = true;
return { ...sourceNode, metadata: { ...sourceNode.metadata, generationBatches: nextBatches } };
});
return changed ? nextNodes : current;
});
}, [projectId, setNodes]);
// 调度只补齐后台返回的用户级活跃任务空位,最终并发仍由后端原子校验。
const scheduleWaitingItems = useCallback(async () => {
if (!projectLoaded || schedulingRef.current) return;
schedulingRef.current = true;
try {
const tasks = await listGenerationTasks(100).catch(() => null);
if (!tasks) return;
const activeTaskCount = tasks.filter((task) => task.status === "queued" || task.status === "running").length;
const currentNodes = nodesRef.current;
const nodeById = new Map(currentNodes.map((node) => [node.id, node]));
const pendingReservations = [...controllersRef.current.keys()].filter((key) => {
const [, itemId] = key.split(":");
const item = currentNodes.flatMap((node) => node.metadata?.generationBatches || []).flatMap((batch) => batch.items).find((candidate) => candidate.id === itemId);
return item ? !nodeById.get(item.nodeId)?.metadata?.taskId : false;
}).length;
let availableSlots = Math.max(0, activeTaskLimit - activeTaskCount - pendingReservations);
if (!availableSlots) return;
const candidates: Array<{ batch: CanvasGenerationBatch; item: CanvasGenerationBatchItem; node: CanvasNodeData }> = [];
for (const sourceNode of currentNodes) {
for (const batch of sourceNode.metadata?.generationBatches || []) {
if (batch.projectId !== projectId || batch.status === "completed" || batch.status === "cancelled") continue;
for (const item of batch.items) {
if (item.status !== "waiting" || availableSlots <= 0) continue;
const node = nodeById.get(item.nodeId);
if (!node) continue;
// 已绑定任务或已有成品的节点交给恢复/对账链路处理,绝不重复提交。
if (node.metadata?.taskId || (node.metadata?.status === "success" && node.metadata.content)) continue;
candidates.push({ batch, item, node });
availableSlots -= 1;
}
}
}
for (const { batch, item, node } of candidates) {
const key = batchItemKey(batch.id, item.id);
if (controllersRef.current.has(key)) continue;
const generationMode: CanvasNodeGenerationMode = batch.mode === "storyboard_video" ? "video" : "image";
const generationConfig = buildGenerationConfig(effectiveConfig, node, generationMode);
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
updateBatch(batch.sourceNodeId, batch.id, (current) => withUpdatedItem(current, item.id, { status: "failed", errorDetails: "生成模型未配置,请完成配置后重试" }));
continue;
}
const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim();
if (!prompt) {
updateBatch(batch.sourceNodeId, batch.id, (current) => withUpdatedItem(current, item.id, { status: "failed", errorDetails: "生成提示词为空" }));
continue;
}
const controller = new AbortController();
controllersRef.current.set(key, controller);
updateBatch(batch.sourceNodeId, batch.id, (current) => withUpdatedItem(current, item.id, { status: "submitting", errorDetails: undefined }));
void handleGenerateNode(node.id, generationMode, prompt, { controller, waitForTaskCapacity: true }).finally(() => {
controllersRef.current.delete(key);
reconcileBatches();
});
}
} finally {
schedulingRef.current = false;
}
}, [activeTaskLimit, effectiveConfig, handleGenerateNode, isAiConfigReady, nodesRef, projectId, projectLoaded, reconcileBatches, updateBatch]);
const retryFailedBatchItems = useCallback((sourceNodeId: string, batchId: string, itemId?: string) => {
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);
if (!batch) return;
const failedItems = batch.items.filter((item) => item.status === "failed" && (!itemId || item.id === itemId));
if (!failedItems.length) return message.info("没有需要重试的失败项");
const nodeById = new Map(nodesRef.current.map((node) => [node.id, node]));
const blockedItems = failedItems.filter((item) => {
const node = nodeById.get(item.nodeId);
return unchangedModeratedPrompt(node?.metadata, node?.metadata?.composerContent || node?.metadata?.prompt || "");
});
const retryableItems = failedItems.filter((item) => !blockedItems.includes(item));
if (blockedItems.length) message.warning(`${blockedItems.length} 个镜头未通过内容审核,请先修改提示词`);
if (!retryableItems.length) return;
const retry = () => {
const retryItemIds = new Set(retryableItems.map((item) => item.id));
const retryNodeIds = new Set(retryableItems.map((item) => item.nodeId));
setNodes((current) => current.map((node) => {
if (node.id === sourceNodeId) {
const batches = (node.metadata?.generationBatches || []).map((currentBatch) => {
if (currentBatch.id !== batchId) return currentBatch;
const items = currentBatch.items.map((item) => retryItemIds.has(item.id) ? { ...item, status: "waiting" as const, taskId: undefined, errorDetails: undefined, costUncertain: false, retryCount: item.retryCount + 1 } : item);
const nextBatch = { ...currentBatch, items, updatedAt: new Date().toISOString() };
return { ...nextBatch, status: generationBatchStatus(nextBatch) };
});
return { ...node, metadata: { ...node.metadata, generationBatches: batches } };
}
if (!retryNodeIds.has(node.id)) return node;
return { ...node, metadata: resetGenerationTaskMetadata(node.metadata) };
}));
message.success(`已将 ${retryableItems.length} 个失败项重新加入等待队列`);
};
if (retryableItems.some((item) => item.costUncertain)) {
modal.confirm({
title: "重试费用状态不确定的任务?",
content: "部分上游请求返回 524,原任务可能已经产生费用。重试会再次提交外部模型任务。",
okText: "仍然重试",
cancelText: "暂不重试",
onOk: retry,
});
return;
}
retry();
}, [message, modal, nodesRef, setNodes]);
const stopRemainingBatchItems = useCallback((sourceNodeId: string, batchId: string) => {
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);
if (!batch) return;
const nodeById = new Map(nodesRef.current.map((node) => [node.id, node]));
const stoppableItems = batch.items.filter((item) => (item.status === "waiting" || item.status === "submitting") && !nodeById.get(item.nodeId)?.metadata?.taskId);
if (!stoppableItems.length) return message.info("没有尚未提交的任务");
modal.confirm({
title: "停止剩余任务?",
content: `将停止 ${stoppableItems.length} 个尚未提交的任务;已经排队或运行的任务会继续。`,
okText: "停止剩余任务",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: () => {
const latestNodeById = new Map(nodesRef.current.map((node) => [node.id, node]));
const latestStoppableItems = stoppableItems.filter((item) => !latestNodeById.get(item.nodeId)?.metadata?.taskId);
const stoppableIds = new Set(latestStoppableItems.map((item) => item.id));
latestStoppableItems.forEach((item) => controllersRef.current.get(batchItemKey(batchId, item.id))?.abort());
updateBatch(sourceNodeId, batchId, (current) => {
const items = current.items.map((item) => stoppableIds.has(item.id) ? { ...item, status: "cancelled" as const, errorDetails: undefined } : item);
const nextBatch = { ...current, items, updatedAt: new Date().toISOString() };
return { ...nextBatch, status: generationBatchStatus(nextBatch) };
});
},
});
}, [message, modal, nodesRef, updateBatch]);
const cancelSubmittedBatchItem = useCallback((sourceNodeId: string, batchId: string, itemId: string) => {
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);
const item = batch?.items.find((candidate) => candidate.id === itemId);
const node = item ? nodesRef.current.find((candidate) => candidate.id === item.nodeId) : undefined;
const taskId = item?.taskId || node?.metadata?.taskId;
if (!item || !taskId) return;
modal.confirm({
title: "取消这个后台任务?",
content: "任务会在后端停止,已经生成完成的其他镜头不会受影响。",
okText: "取消任务",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
try {
const task = await cancelGenerationTask(taskId);
controllersRef.current.get(batchItemKey(batchId, item.id))?.abort();
setNodes((current) => current.map((currentNode) => {
if (currentNode.id === item.nodeId) return { ...currentNode, metadata: { ...currentNode.metadata, ...generationTaskMetadata(task), status: "error", errorDetails: "任务已取消" } };
if (currentNode.id !== sourceNodeId) return currentNode;
const batches = (currentNode.metadata?.generationBatches || []).map((currentBatch) => currentBatch.id === batchId ? withUpdatedItem(currentBatch, item.id, { status: "cancelled", taskId, errorDetails: "任务已取消" }) : currentBatch);
return { ...currentNode, metadata: { ...currentNode.metadata, generationBatches: batches } };
}));
} catch (error) {
message.error(error instanceof Error ? error.message : "任务取消失败");
}
},
});
}, [message, modal, nodesRef, setNodes]);
useEffect(() => {
if (!projectLoaded) return;
reconcileBatches();
}, [nodes, projectLoaded, reconcileBatches]);
useEffect(() => {
if (!projectLoaded) return;
void scheduleWaitingItems();
const timer = window.setInterval(() => {
reconcileBatches();
void scheduleWaitingItems();
}, SCHEDULER_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [projectLoaded, reconcileBatches, scheduleWaitingItems]);
return {
cancelSubmittedBatchItem,
enqueueGenerationBatch,
retryFailedBatchItems,
stopRemainingBatchItems,
};
}
function batchItemKey(batchId: string, itemId: string) {
return `${batchId}:${itemId}`;
}
function itemChanged(item: CanvasGenerationBatchItem, patch: Partial<CanvasGenerationBatchItem>) {
return Object.entries(patch).some(([key, value]) => item[key as keyof CanvasGenerationBatchItem] !== value);
}
function withUpdatedItem(batch: CanvasGenerationBatch, itemId: string, patch: Partial<CanvasGenerationBatchItem>) {
const items = batch.items.map((item) => item.id === itemId ? { ...item, ...patch } : item);
const nextBatch = { ...batch, items, updatedAt: new Date().toISOString() };
return { ...nextBatch, status: generationBatchStatus(nextBatch) };
}
function findBatch(nodes: CanvasNodeData[], sourceNodeId: string, batchId: string) {
return nodes.find((node) => node.id === sourceNodeId)?.metadata?.generationBatches?.find((batch) => batch.id === batchId);
}