Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-generation.ts
ddcat666 5e69106d8f fix(canvas): 画布同步 - 版本冲突先核对并合并,本地保存与云端状态分开展示
本地持久化记录保存时间和日志令牌,同步面板区分本地与云端,不再用提示打断编辑。冲突时先尝试合并,无法合并才保留草稿并暂停自动提交;未登录时生成结果留在本地,登录后再继续上传。
2026-10-09 17:17:38 +08:00

547 lines
30 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId, generationTaskOutputsApplied, shouldRecoverCanvasImageOutputs } from "@/lib/canvas/canvas-generation-task-sync";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
import { markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state";
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskNode, ensureCanvasNodeAsset, retryCanvasAssetSyncAfterRateLimit } from "@/services/project-asset-sync";
import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { generationFailureMetadata } from "@/lib/generation-error";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { hasRemoteUserDataSyncSession } from "@/services/user-data-sync";
import { attachNodeEffectKey } from "@/services/generation-task-materializer";
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
type CanvasGenerationRequest = {
targetNodeId: string;
originNodeId: string;
runningNodeId: string;
controller: AbortController;
};
type UseCanvasGenerationOptions = {
projectId: string;
domainProjectId?: string;
projectLoaded: boolean;
nodes: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
};
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function subscribeCanvasGenerationRecoveryTasks(ids: readonly string[], listener: (task: GenerationTask) => void, subscribe: (ids: readonly string[], listener: (task: GenerationTask) => void) => () => void = subscribeGenerationTasks) {
return subscribe(Array.from(new Set(ids)), listener);
}
export type CanvasGenerationRecoveryContext = {
projectId: string;
controller: AbortController;
signal: AbortSignal;
isCurrentProject: () => boolean;
};
export function createCanvasGenerationRecoveryCoordinator() {
let active:
| {
token: symbol;
controller: AbortController;
}
| undefined;
let transitionTail = Promise.resolve();
return {
switchProject(projectId: string, operation: (context: CanvasGenerationRecoveryContext) => Promise<void>) {
active?.controller.abort();
const token = Symbol(projectId);
const controller = new AbortController();
const previousTail = transitionTail;
const settled = previousTail.then(async () => {
if (controller.signal.aborted) throw new DOMException("The operation was aborted", "AbortError");
await operation({
projectId,
controller,
signal: controller.signal,
isCurrentProject: () => active?.token === token && !controller.signal.aborted,
});
});
active = { token, controller };
transitionTail = settled.then(
() => undefined,
() => undefined,
);
return settled;
},
async abortAndDrain() {
active?.controller.abort();
active = undefined;
await transitionTail;
},
};
}
export async function recoverCanvasGenerationTaskNode(input: {
projectId: string;
node: CanvasNodeData;
completed: GenerationTask;
continuationOnly: boolean;
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
applyGenerationTaskResult: (nodeId: string, task: GenerationTask) => Promise<void>;
signal: AbortSignal;
isCurrentProject?: () => boolean;
consumeContinuation?: typeof consumeCanvasGenerationContinuation;
}) {
const isCurrentProject = () => !input.signal.aborted && (input.isCurrentProject?.() ?? true);
if (!isCurrentProject()) return;
const consumeContinuation = input.consumeContinuation ?? consumeCanvasGenerationContinuation;
const recoveryBaseNodes = useCanvasStore.getState().projects.find((project) => project.id === input.projectId)?.nodes ?? input.nodesRef.current;
try {
if (input.completed.projectId && input.completed.projectId !== input.projectId) throw new Error("生成任务不属于当前画布");
if (!isCurrentProject()) return;
if (input.completed.status === "failed" || input.completed.status === "cancelled") {
throw new Error(input.completed.error || (input.completed.status === "cancelled" ? "任务已取消" : "任务失败"));
}
if (!input.continuationOnly) {
if (input.node.type === CanvasNodeType.Script && input.completed.type === "canvas_text" && input.completed.operation === "storyboard") {
const result = storyboardRowsFromTask(input.completed);
const recoveredNodes = input.nodesRef.current.map((item) =>
item.id === input.node.id
? {
...item,
title: result.title || item.title,
metadata: {
...item.metadata,
...generationTaskMetadata(input.completed),
status: NODE_STATUS_SUCCESS,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
storyboard: { rows: result.rows, visibleColumns: cinematicStoryboardColumns(item.metadata?.storyboard?.visibleColumns), referenceNodeIds: item.metadata?.storyboard?.referenceNodeIds || [] },
},
}
: item,
);
if (!isCurrentProject()) return;
input.nodesRef.current = recoveredNodes;
input.setNodes(recoveredNodes);
} else {
if (!isCurrentProject()) return;
await input.applyGenerationTaskResult(input.node.id, input.completed);
}
}
if (!isCurrentProject()) return;
const continuation = input.nodesRef.current.find((item) => item.id === input.node.id)?.metadata?.agentGenerationContinuation ?? input.node.metadata?.agentGenerationContinuation;
if (continuation?.status === "pending" && continuation.taskId === input.completed.id) {
await consumeContinuation(
input.completed,
continuation,
(nextContinuation) => {
if (!isCurrentProject()) return;
input.setNodes((current) =>
current.map((item) =>
item.id === input.node.id
? {
...item,
metadata: {
...item.metadata,
agentGenerationContinuation: nextContinuation,
...(nextContinuation.effectKey
? {
generationEffectKeys: Array.from(new Set([...(item.metadata?.generationEffectKeys || []), nextContinuation.effectKey])),
}
: {}),
},
}
: item,
),
);
},
{ projectId: input.projectId, nodeId: input.node.id, previousNodes: recoveryBaseNodes, nodesRef: input.nodesRef, setNodes: input.setNodes },
input.signal,
);
}
} catch (error) {
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
if (isCanvasGenerationDurableAckError(error)) return;
const failure = generationFailureMetadata(error, nodeGenerationPrompt(input.node));
input.setNodes((current) =>
current.map((item) =>
item.id === input.node.id
? {
...item,
metadata: {
...item.metadata,
status: input.continuationOnly ? item.metadata?.status : NODE_STATUS_ERROR,
...(input.continuationOnly ? {} : failure),
...(item.metadata?.agentGenerationContinuation?.status === "pending"
? {
agentGenerationContinuation: { ...item.metadata.agentGenerationContinuation, status: "failed" as const },
}
: {}),
},
}
: item,
),
);
}
}
export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, nodes, nodesRef, setNodes }: UseCanvasGenerationOptions) {
const { message } = App.useApp();
const queryClient = useQueryClient();
const generationRequestsRef = useRef(new Map<string, CanvasGenerationRequest>());
const recoveringTaskIdsRef = useRef(new Set<string>());
const autoSavedTaskIdsRef = useRef(new Set<string>());
const consumerControllerRef = useRef(new AbortController());
const recoveryCoordinatorRef = useRef<ReturnType<typeof createCanvasGenerationRecoveryCoordinator> | null>(null);
if (!recoveryCoordinatorRef.current) recoveryCoordinatorRef.current = createCanvasGenerationRecoveryCoordinator();
const [runningNodeId, setRunningNodeId] = useState<string | null>(null);
const [taskDetail, setTaskDetail] = useState<GenerationTask | null>(null);
const [taskDetailLogs, setTaskDetailLogs] = useState<TaskLog[]>([]);
const [taskDetailLoading, setTaskDetailLoading] = useState(false);
const startGenerationRequest = useCallback((targetNodeId: string, originNodeId: string, runningId = originNodeId, controller = new AbortController()) => {
const previous = generationRequestsRef.current.get(targetNodeId);
if (previous?.controller !== controller) previous?.controller.abort();
generationRequestsRef.current.set(targetNodeId, { targetNodeId, originNodeId, runningNodeId: runningId, controller });
return controller;
}, []);
const finishGenerationRequest = useCallback((targetNodeId: string, controller: AbortController) => {
const request = generationRequestsRef.current.get(targetNodeId);
if (request?.controller === controller) generationRequestsRef.current.delete(targetNodeId);
}, []);
const openNodeTaskDetails = useCallback(
async (node: CanvasNodeData) => {
const taskId = node.metadata?.taskId;
if (!taskId) return;
setTaskDetailLoading(true);
setTaskDetailLogs([]);
setTaskDetail({
id: taskId,
type: "",
status: (node.metadata?.taskStatus as GenerationTask["status"]) || "running",
stage: node.metadata?.taskStage,
progress: node.metadata?.taskProgress,
prompt: nodeGenerationPrompt(node),
attempts: 1,
createdAt: node.metadata?.taskCreatedAt || new Date().toISOString(),
updatedAt: node.metadata?.taskUpdatedAt || new Date().toISOString(),
});
try {
const [task, logs] = await Promise.all([queryGenerationTask(taskId), listTaskLogs(taskId)]);
setTaskDetail(task);
setTaskDetailLogs(logs);
} catch (error) {
message.error(error instanceof Error ? error.message : "任务详情加载失败");
} finally {
setTaskDetailLoading(false);
}
},
[message],
);
const bindGenerationTask = useCallback(
(targetNodeId: string, task: GenerationTask) => {
setNodes((current) =>
current.map((node) => {
if (node.id !== targetNodeId) return node;
const failed = task.status === "failed" || task.status === "cancelled";
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey);
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), nodeGenerationPrompt(node) || task.prompt || "") : undefined;
return {
...node,
metadata: {
...node.metadata,
...generationTaskMetadata(task),
status: failed ? NODE_STATUS_ERROR : hasCompletedContent ? NODE_STATUS_SUCCESS : NODE_STATUS_LOADING,
...(failure || { errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined }),
},
};
}),
);
},
[setNodes],
);
const saveGeneratedAsset = useCallback(
async (node: CanvasNodeData, taskId: string, signal?: AbortSignal) => {
const result = await retryCanvasAssetSyncAfterRateLimit(() => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal }), { signal });
if (signal?.aborted) return;
setNodes((current) => current.map((item) => (item.id === node.id && item.metadata?.taskId === taskId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
},
[domainProjectId, projectId, queryClient, setNodes],
);
const applyGenerationTaskResult = useCallback(
async (nodeId: string, task: GenerationTask) => {
const applyStoredTaskResult = async () => {
const signal = consumerControllerRef.current.signal;
const before = nodesRef.current.find((node) => node.id === nodeId);
if (!before) throw new Error("画布中找不到对应任务节点");
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (signal.aborted) throw new DOMException("The operation was aborted", "AbortError");
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id, applied.additionalNodes));
};
if (!task.outputs?.length && task.type === "canvas_text") {
await applyStoredTaskResult();
return;
}
try {
const materialized = await consumeGenerationTaskNode(
task,
nodeId,
0,
async ({ task: materialized, output, effectKey, signal }) => {
await applyCanvasGenerationTaskNodeEffect({
projectId,
nodeId,
task: materialized,
output,
effectKey,
signal,
nodesRef,
setNodes,
});
},
{ signal: consumerControllerRef.current.signal },
);
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (task.status === "succeeded" && (!currentNode || !generationTaskOutputsApplied(currentNode, materialized))) {
// attach effect 可能已经完成,但旧画布快照仍停留在 loading。
// 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。
const output = materialized.outputs?.find((item) => item.outputIndex === 0);
if (!output) throw new Error("生成任务没有可恢复的输出");
await applyCanvasGenerationTaskNodeEffect({ projectId, nodeId, task: materialized, output, effectKey: attachNodeEffectKey(task.id, nodeId, 0), signal: consumerControllerRef.current.signal, nodesRef, setNodes });
}
} catch (error) {
// 成功任务的副作用确认失败时,直接用已持久化结果回写节点,避免永久停留在生成中。
if (task.status === "succeeded") {
await applyStoredTaskResult().catch(() => {
throw error;
});
} else {
if (generationTaskCanReloadResource(task)) {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, resourceReloadAvailable: true } } : node)));
}
throw error;
}
}
},
[nodesRef, projectId, setNodes],
);
const observeSubscribedGenerationTask = useCallback(
(taskId: string, signal: AbortSignal, onUpdate?: (task: GenerationTask) => void) =>
new Promise<GenerationTask>((resolve, reject) => {
let unsubscribe: (() => void) | undefined;
let settled = false;
const cleanup = () => {
signal.removeEventListener("abort", onAbort);
unsubscribe?.();
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
const onTask = (task: GenerationTask) => {
if (settled) return;
onUpdate?.(task);
if (task.status !== "succeeded" && task.status !== "failed" && task.status !== "cancelled") return;
settled = true;
cleanup();
resolve(task);
};
if (signal.aborted) return onAbort();
signal.addEventListener("abort", onAbort, { once: true });
unsubscribe = subscribeCanvasGenerationRecoveryTasks([taskId], onTask);
if (settled) unsubscribe();
}),
[],
);
const recoverInterruptedGenerationTasks = useCallback(
async (startedProjectId: string, signal: AbortSignal, isCurrentProject: () => boolean) => {
if (!isCurrentProject()) return;
const recoveryNodes = nodesRef.current.filter((node) => {
const pendingAgentContinuation = node.metadata?.agentGenerationContinuation?.status === "pending";
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length;
if (aggregateBatchRoot && !pendingAgentContinuation) return false;
return shouldRecoverCanvasImageOutputs(node) || pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
});
const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId);
let projectTasks: GenerationTask[] = [];
if (needsDiscovery) {
try {
projectTasks = (await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal))
.filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_"));
} catch (error) {
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
// 发现接口失败不影响已有 taskId 的精确恢复,也不是“查无任务”的证据。
message.warning({ key: `canvas-task-recovery:${startedProjectId}`, content: "任务列表暂不可用,未绑定任务的节点状态尚未确认;已绑定任务继续恢复。", duration: 6 });
}
if (isCurrentProject() && recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId && !projectTasks.some((task) => generationTaskNodeId(task) === node.id))) {
message.warning({ key: `canvas-task-recovery:${startedProjectId}`, content: "部分节点未在最近任务中找到,已保留原状态。请到任务中心核对结果,勿重复提交。", duration: 6 });
}
}
if (!isCurrentProject()) return;
await Promise.all(
recoveryNodes.map(async (node) => {
const discoveredTask = projectTasks.find((task) => generationTaskNodeId(task) === node.id);
const taskId = node.metadata?.taskId || node.metadata?.agentGenerationContinuation?.taskId || discoveredTask?.id;
if (!taskId) {
if (!isCurrentProject()) return;
setNodes((current) =>
isCurrentProject() ? current.map((item) => (item.id === node.id ? markCanvasTaskRecoveryUnconfirmed(item, undefined, "最近任务中未找到对应记录,状态尚未确认;请到任务中心核对,勿重复提交。") : item)) : current,
);
return;
}
if (recoveringTaskIdsRef.current.has(taskId)) return;
recoveringTaskIdsRef.current.add(taskId);
const continuationOnly = !node.metadata?.taskId && node.metadata?.agentGenerationContinuation?.taskId === taskId && !discoveredTask;
try {
const completed = await observeSubscribedGenerationTask(taskId, signal, (task) => {
const live = nodesRef.current.find((item) => item.id === node.id);
if (isCurrentProject() && !continuationOnly && live && (!live.metadata?.taskId || live.metadata.taskId === taskId)) bindGenerationTask(node.id, task);
});
if (!isCurrentProject()) return;
await recoverCanvasGenerationTaskNode({
projectId: startedProjectId,
node,
completed,
continuationOnly,
nodesRef,
setNodes,
applyGenerationTaskResult,
signal,
isCurrentProject,
});
} catch (error) {
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
const failure = generationFailureMetadata(error, nodeGenerationPrompt(node));
setNodes((current) =>
isCurrentProject()
? current.map((item) =>
item.id === node.id && (!item.metadata?.taskId || item.metadata.taskId === taskId)
? {
...item,
metadata: {
...item.metadata,
...(continuationOnly ? {} : markCanvasTaskRecoveryUnconfirmed(item, item.metadata?.taskId, failure.errorDetails || "任务恢复暂不可用,请到任务中心核对。").metadata),
...(item.metadata?.agentGenerationContinuation?.status === "pending"
? {
agentGenerationContinuation: { ...item.metadata.agentGenerationContinuation, status: "failed" as const },
}
: {}),
},
}
: item,
)
: current,
);
} finally {
recoveringTaskIdsRef.current.delete(taskId);
}
}),
);
if (!isCurrentProject()) return;
setNodes((current) =>
isCurrentProject()
? current.map((node) => {
return reconcileImageBatchRoot(node, current);
})
: current,
);
},
[applyGenerationTaskResult, bindGenerationTask, message, nodesRef, observeSubscribedGenerationTask, setNodes],
);
useEffect(() => {
const coordinator = recoveryCoordinatorRef.current!;
if (!projectLoaded) {
void coordinator.abortAndDrain();
return;
}
void coordinator
.switchProject(projectId, async (context) => {
recoveringTaskIdsRef.current.clear();
consumerControllerRef.current = context.controller;
await runGenerationConsumer(context.signal, async (signal) => recoverInterruptedGenerationTasks(context.projectId, signal, () => !signal.aborted && context.isCurrentProject()));
})
.catch((error) => {
if (error instanceof Error && error.name === "AbortError") return;
message.warning({ key: `canvas-task-recovery:${projectId}`, content: "任务恢复暂不可用,已保留画布状态。请检查网络后刷新,或到任务中心核对结果。", duration: 6 });
});
return () => {
void coordinator.abortAndDrain();
};
}, [message, projectId, projectLoaded, recoverInterruptedGenerationTasks]);
useEffect(
() => () => {
void recoveryCoordinatorRef.current?.abortAndDrain();
consumerControllerRef.current.abort();
generationRequestsRef.current.forEach((request) => request.controller.abort());
generationRequestsRef.current.clear();
},
[],
);
useEffect(() => {
if (!projectLoaded) return;
nodes.forEach((node) => {
const taskId = node.metadata?.taskId;
if (!taskId || !node.metadata?.content || node.metadata.status !== NODE_STATUS_SUCCESS || (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio)) return;
const saveKey = `${taskId}:${node.id}:${domainProjectId || "personal"}`;
if (autoSavedTaskIdsRef.current.has(saveKey)) return;
autoSavedTaskIdsRef.current.add(saveKey);
void runGenerationConsumer(consumerControllerRef.current.signal, async (signal) => {
await saveGeneratedAsset(node, taskId, signal);
}).catch((error) => {
autoSavedTaskIdsRef.current.delete(saveKey);
if (error instanceof Error && error.name === "AbortError") return;
const detail = error instanceof Error ? error.message : "未知错误";
const content = hasRemoteUserDataSyncSession()
? `生成结果已保留,本地素材已保存,云端项目资产稍后重试:${detail}`
: "生成结果已保留在本地素材库,登录后会继续同步到云端";
message.warning({
key: `canvas-asset-sync:${projectId}`,
content,
duration: 4,
});
});
});
}, [domainProjectId, message, nodes, projectId, projectLoaded, saveGeneratedAsset]);
return {
applyGenerationTaskResult,
bindGenerationTask,
finishGenerationRequest,
openNodeTaskDetails,
runningNodeId,
setRunningNodeId,
setTaskDetail,
startGenerationRequest,
taskDetail,
taskDetailLoading,
taskDetailLogs,
};
}