fix(canvas): 画布批量生成 - 修复节点丢失、资源引用及刷新状态不一致

This commit is contained in:
ddCat
2026-09-21 21:13:29 +08:00
parent dc1ad68015
commit 6f45240599
26 changed files with 533 additions and 179 deletions

View File

@@ -2,10 +2,10 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { cancelIncompleteImageBatch, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
import { cancelIncompleteImageBatch, hasImageBatchResult, reconcileImageBatchRoot, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, resetGenerationTaskMetadata, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
@@ -54,7 +54,7 @@ export async function executeImageGeneration({
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const isExistingImageNode = isImageNode && Boolean(sourceNode?.metadata?.content);
const isExistingImageNode = isImageNode && hasImageBatchResult(sourceNode);
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] };
const workingNodes = retired.nodes;
@@ -70,7 +70,7 @@ export async function executeImageGeneration({
const imageConfig = requestedImageSize || imageDefaults;
// auto 图生图沿用来源节点尺寸;用户明确选择比例时必须以目标比例创建节点。
const referenceNode = referenceImages.length === 1 ? canvasNodes.find((node) => node.id === referenceImages[0].id && node.type === CanvasNodeType.Image) : undefined;
const imageSizeSource = requestedImageSize ? undefined : isImageNode && sourceNode?.metadata?.content ? sourceNode : referenceNode;
const imageSizeSource = requestedImageSize ? undefined : isExistingImageNode ? sourceNode : referenceNode;
const outputNodeSize = imageSizeSource ? { width: imageSizeSource.width, height: imageSizeSource.height } : imageConfig;
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
const parentWidth = sourceNode?.width || parentConfig.width;
@@ -95,7 +95,7 @@ export async function executeImageGeneration({
width: rootWidth,
height: rootHeight,
metadata: {
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
...resetGenerationTaskMetadata(reuseSourceNode ? sourceNode?.metadata : undefined, NODE_STATUS_LOADING),
...canvasGenerationPromptMetadata(prompt, effectivePrompt),
status: NODE_STATUS_LOADING,
size: generationConfig.size,
@@ -105,6 +105,8 @@ export async function executeImageGeneration({
batchUsesReferenceImages: referenceImages.length > 0,
primaryImageId: undefined,
content: reuseSourceNode ? "" : undefined,
storageKey: undefined,
assetId: undefined,
...generationMetadata,
...styleMetadata,
...skillMetadata,
@@ -145,7 +147,7 @@ export async function executeImageGeneration({
...workingNodes.map((node) => {
if (node.id !== nodeId) return node;
if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: rootNode.metadata };
if (isImageNode) return node;
return {
...node,
@@ -211,7 +213,7 @@ export async function executeImageGeneration({
setNodes((current) => {
const child = current.find((node) => node.id === targetId);
const root = current.find((node) => node.id === rootId);
if (!child?.metadata?.content || !root || root.metadata?.primaryImageId) return current;
if (!child || !hasImageBatchResult(child) || !root || root.metadata?.primaryImageId) return current;
const center = { x: root.position.x + root.width / 2, y: root.position.y + root.height / 2 };
const geometry = root.metadata?.locked
? {}
@@ -290,6 +292,7 @@ export async function executeImageGeneration({
};
}
if (node.id === rootId) {
if (count > 1) return reconcileImageBatchRoot(node, current);
return {
...node,
metadata: {

View File

@@ -1,5 +1,6 @@
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -275,24 +276,14 @@ function InfiniteCanvasPage() {
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
const nodesRef = useRef<CanvasNodeData[]>([]);
const [nodes, setNodesState] = useState<CanvasNodeData[]>([]);
const setNodes = useCallback<Dispatch<SetStateAction<CanvasNodeData[]>>>((value) => {
if (typeof value === "function") {
setNodesState((current) => {
const next = stampCanvasNodeChanges(current, value(current));
nodesRef.current = next;
return next;
});
return;
}
const next = stampCanvasNodeChanges(nodesRef.current, value);
nodesRef.current = next;
setNodesState(next);
}, []);
const setNodes = useMemo(() => createCanvasStateWriter(nodesRef, setNodesState, stampCanvasNodeChanges), []);
const [nodeStackOrder, setNodeStackOrder] = useState<CanvasNodeStackOrder>([]);
const bringNodeToFront = useCallback((nodeId: string) => {
setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId));
}, []);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [connections, setConnectionsState] = useState<CanvasConnection[]>([]);
const connectionsRef = useRef<CanvasConnection[]>([]);
const setConnections = useMemo(() => createCanvasStateWriter(connectionsRef, setConnectionsState), []);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
@@ -370,7 +361,6 @@ function InfiniteCanvasPage() {
});
}, [nodes]);
const connectionsRef = useRef(connections);
const chatSessionsRef = useRef(chatSessions);
const activeChatIdRef = useRef(activeChatId);
const selectedNodeIdsRef = useRef(selectedNodeIds);
@@ -637,8 +627,6 @@ function InfiniteCanvasPage() {
}, [dialogNodeId]);
useLayoutEffect(() => {
nodesRef.current = nodes;
connectionsRef.current = connections;
chatSessionsRef.current = chatSessions;
activeChatIdRef.current = activeChatId;
selectedNodeIdsRef.current = selectedNodeIds;

View File

@@ -3,6 +3,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } 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";
@@ -254,7 +257,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
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);
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey);
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "") : undefined;
return {
...node,
@@ -274,7 +277,8 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
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 });
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
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],
@@ -283,10 +287,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
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("画布中找不到对应任务节点");
nodesRef.current = applied.nodes;
setNodes(applied.nodes);
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id));
};
if (!task.outputs?.length && task.type === "canvas_text") {
await applyStoredTaskResult();
@@ -311,7 +318,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
},
{ signal: consumerControllerRef.current.signal },
);
const currentNode = nodesRef.current.find((node) => node.id === nodeId || node.metadata?.taskId === task.id);
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (task.status === "succeeded" && (!currentNode?.metadata?.content || currentNode.metadata.status !== NODE_STATUS_SUCCESS)) {
// attach effect 可能已经完成,但旧画布快照仍停留在 loading。
// 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。
@@ -370,19 +377,25 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
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 && !node.metadata.taskId;
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length;
if (aggregateBatchRoot && !pendingAgentContinuation) return false;
return 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);
const projectTasks = needsDiscovery
? (
await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal).catch((error) => {
if (!isCurrentProject()) throw error;
return [];
})
).filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_"))
: [];
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) => {
@@ -391,7 +404,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
if (!taskId) {
if (!isCurrentProject()) return;
setNodes((current) =>
isCurrentProject() ? current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "页面刷新后找不到对应任务,请重新生成。" } } : item)) : current,
isCurrentProject() ? current.map((item) => (item.id === node.id ? markCanvasTaskRecoveryUnconfirmed(item, undefined, "最近任务中未找到对应记录,状态尚未确认;请到任务中心核对,勿重复提交。") : item)) : current,
);
return;
}
@@ -400,7 +413,8 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const continuationOnly = !node.metadata?.taskId && node.metadata?.agentGenerationContinuation?.taskId === taskId && !discoveredTask;
try {
const completed = await observeSubscribedGenerationTask(taskId, signal, (task) => {
if (isCurrentProject() && !continuationOnly) bindGenerationTask(node.id, 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({
@@ -420,13 +434,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
setNodes((current) =>
isCurrentProject()
? current.map((item) =>
item.id === node.id
item.id === node.id && (!item.metadata?.taskId || item.metadata.taskId === taskId)
? {
...item,
metadata: {
...item.metadata,
status: continuationOnly ? item.metadata?.status : NODE_STATUS_ERROR,
...(continuationOnly ? {} : failure),
...(continuationOnly ? {} : markCanvasTaskRecoveryUnconfirmed(item, item.metadata?.taskId, failure.errorDetails || "任务恢复暂不可用,请到任务中心核对。").metadata),
...(item.metadata?.agentGenerationContinuation?.status === "pending"
? {
agentGenerationContinuation: { ...item.metadata.agentGenerationContinuation, status: "failed" as const },
@@ -447,35 +460,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
setNodes((current) =>
isCurrentProject()
? current.map((node) => {
if (!node.metadata?.isBatchRoot || !node.metadata.batchChildIds?.length || node.metadata.taskId) return node;
const children = node.metadata.batchChildIds.map((id) => current.find((item) => item.id === id)).filter(Boolean) as CanvasNodeData[];
const primary = children.find((item) => item.id === node.metadata?.primaryImageId && item.metadata?.content) || children.find((item) => item.metadata?.content);
const loading = children.some((item) => item.metadata?.status === NODE_STATUS_LOADING);
const failed = children.find((item) => item.metadata?.status === NODE_STATUS_ERROR);
return {
...node,
metadata: {
...node.metadata,
...(primary
? {
content: primary.metadata?.content,
storageKey: primary.metadata?.storageKey,
mimeType: primary.metadata?.mimeType,
bytes: primary.metadata?.bytes,
naturalWidth: primary.metadata?.naturalWidth,
naturalHeight: primary.metadata?.naturalHeight,
primaryImageId: primary.id,
}
: {}),
status: primary ? NODE_STATUS_SUCCESS : loading ? NODE_STATUS_LOADING : NODE_STATUS_ERROR,
errorDetails: primary ? undefined : failed?.metadata?.errorDetails || "全部图片生成失败",
},
};
return reconcileImageBatchRoot(node, current);
})
: current,
);
},
[applyGenerationTaskResult, bindGenerationTask, nodesRef, observeSubscribedGenerationTask, setNodes],
[applyGenerationTaskResult, bindGenerationTask, message, nodesRef, observeSubscribedGenerationTask, setNodes],
);
useEffect(() => {
@@ -491,12 +481,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
await runGenerationConsumer(context.signal, async (signal) => recoverInterruptedGenerationTasks(context.projectId, signal, () => !signal.aborted && context.isCurrentProject()));
})
.catch((error) => {
if (!(error instanceof Error && error.name === "AbortError")) throw error;
if (error instanceof Error && error.name === "AbortError") return;
message.warning({ key: `canvas-task-recovery:${projectId}`, content: "任务恢复暂不可用,已保留画布状态。请检查网络后刷新,或到任务中心核对结果。", duration: 6 });
});
return () => {
void coordinator.abortAndDrain();
};
}, [projectId, projectLoaded, recoverInterruptedGenerationTasks]);
}, [message, projectId, projectLoaded, recoverInterruptedGenerationTasks]);
useEffect(
() => () => {

View File

@@ -7,6 +7,7 @@ import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CO
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
import type { Skill } from "@/services/api/skills";
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
import type { DirectorScene } from "@/types/director";
@@ -154,6 +155,7 @@ export function useCanvasRenderModel({
const viewWidth = viewportSize.width / viewport.k;
const viewHeight = viewportSize.height / viewport.k;
return {
view: { left: viewLeft, top: viewTop, right: viewLeft + viewWidth, bottom: viewTop + viewHeight },
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
};
@@ -177,30 +179,19 @@ export function useCanvasRenderModel({
const visibleNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
const renderedNodeIds = renderedNodeIdsRef.current;
const renderBudget = canvasNodeRenderBudget(viewport.k);
const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget));
const candidates = nodeSpatialIndex
.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const candidateIds = new Set(candidates.map((node) => node.id));
for (const nodeId of forcedNodeIds) {
if (candidateIds.has(nodeId)) continue;
const node = nodeById.get(nodeId);
if (node) candidates.push(node);
}
const prioritized = candidates.filter((node) => forcedNodeIds.has(node.id));
const remaining = candidates.filter((node) => !forcedNodeIds.has(node.id)).slice(0, Math.max(0, renderBudget - prioritized.length));
[...prioritized, ...remaining].forEach((node) => {
if (renderHiddenNodeIds.has(node.id)) return;
const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id);
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left && node.position.x < renderBounds.enter.right && node.position.y + node.height > renderBounds.enter.top && node.position.y < renderBounds.enter.bottom;
if (!retained && !insideEnterBounds) return;
selectCanvasVisibleNodes({
index: nodeSpatialIndex,
nodeById,
...renderBounds,
hiddenIds: renderHiddenNodeIds,
retainedIds: renderedNodeIdsRef.current,
forcedIds: new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]),
budget: canvasNodeRenderBudget(viewport.k),
}).forEach((node) => {
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds, viewport.k]);
useEffect(() => {
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
}, [visibleNodes]);