fix(canvas): 画布资源恢复 - 生成结果下载或 OSS 落盘失败支持原任务重新加载

This commit is contained in:
ddCat
2026-08-25 15:06:45 +08:00
parent 0cbe24a19f
commit 6ed1eb5a7c
15 changed files with 116 additions and 35 deletions

View File

@@ -72,3 +72,11 @@ description: 影策已实现功能的手工验证清单与未确认边界
- [ ] 在一台依赖已安装和一台全新工作区分别运行 `.\scripts\start-local.ps1`。
- [ ] 验证端口被占用时脚本会安全退出,且不会关闭已有进程或使用正式开发数据目录。
## 画布生成结果资源恢复
- [x] `web` 前端 TypeScript 检查与生产构建通过。
- [ ] 模拟图片生成任务已成功但结果下载失败,确认节点显示“重新加载资源”和“重新生成”两个按钮。
- [ ] 点击“重新加载资源”,确认只重新查询并消费原任务结果,不创建新的生成任务或重复扣费。
- [ ] 模拟视频结果落盘/OSS 上传失败后再次加载,确认恢复成功会清除错误状态并保留原任务信息。
- [ ] 点击“重新生成”,确认仍走原有生成重试流程;普通生成失败仍只显示“重新生成”。

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { AlertCircle, BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
@@ -43,6 +43,7 @@ type CanvasNodeContentProps = {
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
onRetry?: (node: CanvasNodeData) => void;
onReloadResource?: (node: CanvasNodeData) => void;
onOpenTaskDetails?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
@@ -58,7 +59,7 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) {
if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} onReloadResource={props.onReloadResource} />;
const Renderer = nodeContentRenderers[props.node.type];
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
@@ -196,7 +197,7 @@ function shortTaskId(id: string) {
return `${id.slice(0, 14)}...${id.slice(-4)}`;
}
function ErrorContent({ node, theme, onRetry }: Pick<CanvasNodeContentProps, "node" | "theme" | "onRetry">) {
function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick<CanvasNodeContentProps, "node" | "theme" | "onRetry" | "onReloadResource">) {
const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
const errorDisplayTask = {
provider: node.metadata?.taskProvider,
@@ -217,6 +218,29 @@ function ErrorContent({ node, theme, onRetry }: Pick<CanvasNodeContentProps, "no
<div className="rounded-[var(--r-sm)] px-3 py-2 text-[var(--fs-label)] leading-4" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
修改节点提示词后,可重新点击生成。
</div>
) : node.metadata?.resourceReloadAvailable ? (
<div className="flex flex-wrap justify-center gap-2">
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--r-md)] px-3 text-xs font-medium transition-colors"
style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}
onClick={(event) => { event.stopPropagation(); onReloadResource?.(node); }}
onMouseDown={(event) => event.stopPropagation()}
>
<Download className="size-3.5" />
重新加载资源
</button>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--r-md)] px-3 text-xs font-medium transition-colors"
style={{ background: theme.toolbar.itemHover, color: theme.node.text }}
onClick={(event) => { event.stopPropagation(); onRetry?.(node); }}
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
重新生成
</button>
</div>
) : (
<button
type="button"
@@ -229,7 +253,7 @@ function ErrorContent({ node, theme, onRetry }: Pick<CanvasNodeContentProps, "no
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
{node.metadata?.isBatchRoot ? "重试失败项" : "重试"}
{node.metadata?.isBatchRoot ? "重新生成失败项" : "重新生成"}
</button>
)}
</div>
@@ -335,7 +359,7 @@ function ImageNodeContent(props: CanvasNodeContentProps) {
const content = props.node.metadata?.status === "loading"
? <LoadingContent node={props.node} theme={props.theme} />
: props.node.metadata?.status === "error"
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} onReloadResource={props.onReloadResource} />
: <EmptyImageContent {...props} isBatchRoot={false} />;
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} theme={props.theme} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>;
}

View File

@@ -49,6 +49,7 @@ type CanvasNodeProps = {
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
onReloadResource?: (node: CanvasNodeData) => void;
onOpenTaskDetails?: (node: CanvasNodeData) => void;
onOpenVersions?: (node: CanvasNodeData) => void;
onViewImage?: (node: CanvasNodeData) => void;
@@ -92,6 +93,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch,
onSetBatchPrimary,
onRetry,
onReloadResource,
onOpenTaskDetails,
onOpenVersions,
onViewImage,
@@ -360,6 +362,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
onRetry={onRetry}
onReloadResource={onReloadResource}
onOpenTaskDetails={onOpenTaskDetails}
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
@@ -425,7 +428,8 @@ export const CanvasNode = React.memo(function CanvasNode({
{hasImageContent ? (
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
) : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重试" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" && data.metadata.resourceReloadAvailable ? <BatchChildActionButton theme={theme} label="重新加载资源" icon={<Download className="size-3.5" />} onClick={() => onReloadResource?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重新生成" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null}
</div>
</div>
@@ -495,6 +499,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.onToggleBatch === next.onToggleBatch &&
previous.onSetBatchPrimary === next.onSetBatchPrimary &&
previous.onRetry === next.onRetry &&
previous.onReloadResource === next.onReloadResource &&
previous.onOpenTaskDetails === next.onOpenTaskDetails &&
previous.onOpenVersions === next.onOpenVersions &&
previous.onViewImage === next.onViewImage &&

View File

@@ -35,6 +35,11 @@ export function generationTaskMode(task: GenerationTask, fallback?: CanvasGenera
return fallback || "image";
}
export function generationTaskCanReloadResource(task: GenerationTask) {
const mode = generationTaskMode(task);
return task.status === "succeeded" && (mode === "image" || mode === "video" || mode === "audio") && (Boolean(task.resultJson) || Boolean(task.outputs?.length));
}
export function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
return {
content: image.url,
@@ -46,6 +51,7 @@ export function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
mimeType: image.mimeType,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
};
}
@@ -62,6 +68,7 @@ export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
durationMs: video.durationMs,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
};
}
@@ -76,6 +83,7 @@ export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
durationMs: audio.durationMs,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
};
}
@@ -162,7 +170,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
return {
...node,
type: CanvasNodeType.Text,
metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined },
metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined },
};
}

View File

@@ -20,6 +20,7 @@ export function markImageBatchRetrying(rootId: string, childIds: string[], nodes
...(node.id === rootId ? { batchFailedCount: childIds.length } : {}),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
};
@@ -35,6 +36,7 @@ export function restoreUnsubmittedImageBatchChild(current: CanvasNodeData, origi
status: "error",
errorDetails: original.metadata?.errorDetails || "重试请求未提交",
generationErrorCode: original.metadata?.generationErrorCode,
resourceReloadAvailable: original.metadata?.resourceReloadAvailable,
failedPromptFingerprint: original.metadata?.failedPromptFingerprint,
},
};
@@ -77,6 +79,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
metadata.status = loading ? "loading" : failed ? "error" : "idle";
metadata.errorDetails = failed?.metadata?.errorDetails;
metadata.generationErrorCode = failed?.metadata?.generationErrorCode;
metadata.resourceReloadAvailable = failed?.metadata?.resourceReloadAvailable;
metadata.failedPromptFingerprint = failed?.metadata?.failedPromptFingerprint;
}

View File

@@ -170,6 +170,7 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
status,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
};
delete next.taskId;

View File

@@ -97,6 +97,7 @@ export async function executeImageGeneration({
...styleMetadata,
imageBatchExpanded: count > 1 ? true : undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
};
@@ -115,6 +116,7 @@ export async function executeImageGeneration({
...generationMetadata,
...styleMetadata,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}));

View File

@@ -51,6 +51,7 @@ export async function executeVideoGeneration({
status: NODE_STATUS_LOADING,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
model: generationConfig.model,
size: generationConfig.size,

View File

@@ -64,6 +64,7 @@ type CanvasProjectWorldLayersProps = {
onToggleBatch: (nodeId: string) => void;
onSetBatchPrimary: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onReloadResource: (node: CanvasNodeData) => void;
onOpenTaskDetails: (node: CanvasNodeData) => void;
onOpenVersions: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
@@ -161,6 +162,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
onToggleBatch={props.onToggleBatch}
onSetBatchPrimary={props.onSetBatchPrimary}
onRetry={props.onRetry}
onReloadResource={props.onReloadResource}
onOpenTaskDetails={props.onOpenTaskDetails}
onOpenVersions={props.onOpenVersions}
onViewImage={props.onViewImage}

View File

@@ -85,6 +85,6 @@ export async function executeTextGeneration({
if (controller.signal.aborted) return;
void answers;
if (isConfigNode) {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } } : node)));
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : node)));
}
}

View File

@@ -88,6 +88,7 @@ import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
import { CanvasRefreshShell } from "./canvas-refresh-shell";
import { queryGenerationTask } from "@/services/api/task-center";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
@@ -1515,6 +1516,21 @@ function InfiniteCanvasPage() {
bindGenerationTask,
applyGenerationTaskResult,
});
const reloadCanvasNodeResource = useCallback(
async (node: CanvasNodeData) => {
const taskId = node.metadata?.taskId;
if (!taskId || !node.metadata?.resourceReloadAvailable) return;
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "loading", taskStage: "正在重新加载资源", errorDetails: undefined } } : item)));
try {
const task = await queryGenerationTask(taskId);
if (task.status !== "succeeded") throw new Error("原生成任务尚未成功,无法重新加载资源");
await applyGenerationTaskResult(node.id, task);
} catch (error) {
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: error instanceof Error ? error.message : "资源重新加载失败", resourceReloadAvailable: true } } : item)));
}
},
[applyGenerationTaskResult, setNodes],
);
const reconcileImageBatchRootNode = useCallback((rootId: string) => {
setNodes((current) => {
const root = current.find((item) => item.id === rootId);
@@ -2002,6 +2018,7 @@ function InfiniteCanvasPage() {
onToggleBatch={toggleBatchExpanded}
onSetBatchPrimary={setBatchPrimary}
onRetry={retryCanvasNode}
onReloadResource={reloadCanvasNodeResource}
onOpenTaskDetails={openCanvasNodeTaskDetails}
onOpenVersions={openCanvasNodeVersions}
onViewImage={viewCanvasNodeImage}

View File

@@ -124,6 +124,7 @@ export function useCanvasGenerationExecutor({
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
@@ -235,7 +236,7 @@ export function useCanvasGenerationExecutor({
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } } : node,
node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : node,
),
);

View File

@@ -201,7 +201,7 @@ export function useCanvasGenerationRetry({
: undefined;
setRunningNodeId(node.id);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } } : item)));
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : item)));
const controller = startGenerationRequest(node.id, sourceNode.id, node.id);
const retryContext = node.metadata?.taskId ? await createGenerationRetryContext(node.metadata.taskId, node.metadata.attemptGroupId) : {};
const runAndConsumeRetry = async (input: Parameters<typeof runBackendCanvasGenerationTask>[0]) => {

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskNode, ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
@@ -120,6 +120,7 @@ export async function recoverCanvasGenerationTaskNode(input: {
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 || [] },
},
@@ -261,7 +262,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
...node.metadata,
...generationTaskMetadata(task),
status: failed ? NODE_STATUS_ERROR : hasCompletedContent ? NODE_STATUS_SUCCESS : NODE_STATUS_LOADING,
...(failure || { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined }),
...(failure || { errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined }),
},
};
}),
@@ -281,30 +282,37 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const applyGenerationTaskResult = useCallback(
async (nodeId: string, task: GenerationTask) => {
if (!task.outputs?.length && task.type === "canvas_text") {
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
setNodes((current) => current.map((node) => (node.id === applied.nodeId ? applied.node! : node)));
return;
try {
if (!task.outputs?.length && task.type === "canvas_text") {
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
setNodes((current) => current.map((node) => (node.id === applied.nodeId ? applied.node! : node)));
return;
}
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 },
);
} catch (error) {
if (generationTaskCanReloadResource(task)) {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, resourceReloadAvailable: true } } : node)));
}
throw error;
}
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 },
);
},
[nodesRef, projectId, setNodes],
);

View File

@@ -192,6 +192,7 @@ export type CanvasNodeMetadata = {
locked?: boolean;
errorDetails?: string;
generationErrorCode?: string;
resourceReloadAvailable?: boolean;
failedPromptFingerprint?: string;
fontSize?: number;
generationMode?: CanvasGenerationMode;