feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCatandzkhyww committed 2026-08-15 12:45:29 +08:00
1 parent f549767cda
commit 914717cae6
179 files changed
+49746 -1897

No files matched your search

@@ -16,6 +16,7 @@ export type CanvasGenerationExecutorDependencies = {
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
applyGenerationTaskResult: (targetNodeId: string, task: GenerationTask) => Promise<void>;
showError: (content: string) => void;
};
@@ -31,5 +32,7 @@ export type CanvasGenerationExecution = CanvasGenerationExecutorDependencies & {
controller: AbortController;
editingTextNode: boolean;
styleMetadata: { styleProfileJson?: string; styleExecutionPlan?: StyleExecutionPlan };
taskContext?: { conversationId?: string; messageId?: string };
retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string };
registerPendingNodeIds: (nodeIds: string[]) => void;
};
@@ -2,11 +2,9 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, limitCanvasImageReferences, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, limitCanvasImageReferences, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
import { uploadImage } from "@/services/image-storage";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -34,7 +32,10 @@ export async function executeImageGeneration({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
styleMetadata,
taskContext,
retryContext,
showError,
registerPendingNodeIds,
}: CanvasGenerationExecution) {
@@ -54,7 +55,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 : isImageNode && sourceNode?.metadata?.content ? 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;
@@ -100,7 +101,16 @@ export async function executeImageGeneration({
position: imageGenerationChildPosition(rootNode.position, rootNode.width, outputNodeSize, index),
width: outputNodeSize.width,
height: outputNodeSize.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, size: generationConfig.size, batchRootId: count > 1 && !directCopiedBatch ? rootId : undefined, ...generationMetadata, ...styleMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined },
metadata: {
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
size: generationConfig.size,
batchRootId: count > 1 && !directCopiedBatch ? rootId : undefined,
...generationMetadata,
...styleMetadata,
generationErrorCode: undefined,
failedPromptFingerprint: undefined,
},
}));
const batchConnections = directCopiedBatch
? childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))
@@ -139,32 +149,65 @@ export async function executeImageGeneration({
await Promise.all(
targetIds.map(async (targetId) => {
try {
const result = await runBackendCanvasGenerationTask({
projectId,
nodeId: targetId,
mode: "image",
prompt: effectivePrompt,
config: { ...generationConfig, count: "1" },
referenceImages,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables, ...styleMetadata },
onTaskCreated: (task) => bindGenerationTask(targetId, task),
});
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const imageSize = imageSizeSource ? outputNodeSize : fitNodeSize(uploaded.width, uploaded.height, outputNodeSize.width, outputNodeSize.height);
setNodes((current) => {
const root = directCopiedBatch ? undefined : current.find((node) => node.id === rootId);
return current.map((node) => {
if (node.id !== targetId && (!root || node.id !== rootId)) return node;
const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 };
const geometry = node.metadata?.locked ? {} : { position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height };
if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId)) return { ...node, ...geometry, metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId } };
if (node.id === targetId) return { ...node, ...geometry, metadata: { ...node.metadata, ...imageMetadata(uploaded) } };
return node;
await runCanvasGenerationTaskToConsumer(
{
projectId,
nodeId: targetId,
...retryContext,
mode: "image",
prompt: effectivePrompt,
config: { ...generationConfig, count: "1" },
referenceImages,
signal: controller.signal,
metadata: {
sourceNodeId: nodeId,
...taskContext,
resolvedCharacterVersions: generationContext.resolvedCharacterVersions,
promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation,
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
...styleMetadata,
},
},
{
bindTask: (task) => bindGenerationTask(targetId, task),
consumeTask: (task) => applyGenerationTaskResult(targetId, task),
},
);
if (targetId !== rootId && !directCopiedBatch) {
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;
const center = { x: root.position.x + root.width / 2, y: root.position.y + root.height / 2 };
const geometry = root.metadata?.locked
? {}
: {
width: child.width,
height: child.height,
position: { x: center.x - child.width / 2, y: center.y - child.height / 2 },
};
return current.map((node) =>
node.id === rootId
? {
...node,
...geometry,
metadata: {
...node.metadata,
content: child.metadata?.content,
storageKey: child.metadata?.storageKey,
mimeType: child.metadata?.mimeType,
bytes: child.metadata?.bytes,
naturalWidth: child.metadata?.naturalWidth,
naturalHeight: child.metadata?.naturalHeight,
assetId: child.metadata?.assetId,
primaryImageId: targetId,
status: NODE_STATUS_SUCCESS,
},
}
: node,
);
});
});
}
hasSuccess = true;
if (isConfigNode) setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)));
return true;
@@ -1,12 +1,9 @@
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { audioMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video";
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -28,8 +25,11 @@ export async function executeVideoGeneration({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
registerPendingNodeIds,
styleMetadata,
taskContext,
retryContext,
}: CanvasGenerationExecution) {
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
@@ -89,16 +89,34 @@ export async function executeVideoGeneration({
startGenerationRequest(videoId, nodeId, nodeId, controller);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoices: generationContext.resolvedCharacterVoices, promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables, ...videoGenerationMetadata, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
setNodes((current) => current.map((node) => {
const isVersionSibling = Boolean(versionRootId && (node.metadata?.versionOfNodeId || node.id) === versionRootId);
if (node.id !== videoId) return isVersionSibling ? { ...node, metadata: { ...node.metadata, versionPrimary: false } } : node;
const geometry = node.metadata?.locked ? {} : { width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 } };
return { ...node, ...geometry, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext), ...videoGenerationMetadata, ...(versionRootId ? { versionPrimary: true } : {}) } };
}));
await runCanvasGenerationTaskToConsumer(
{
projectId,
nodeId: videoId,
...retryContext,
mode: "video",
prompt: effectivePrompt,
config: generationConfig,
referenceImages: generationContext.referenceImages,
referenceVideos: generationContext.referenceVideos,
referenceAudios: generationContext.referenceAudios,
signal: controller.signal,
metadata: {
sourceNodeId: nodeId,
...taskContext,
resolvedCharacterVersions: generationContext.resolvedCharacterVersions,
resolvedCharacterVoices: generationContext.resolvedCharacterVoices,
promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation,
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
...videoGenerationMetadata,
...styleMetadata,
},
},
{
bindTask: (task) => bindGenerationTask(videoId, task),
consumeTask: (task) => applyGenerationTaskResult(videoId, task),
},
);
} finally {
finishGenerationRequest(videoId, controller);
}
@@ -117,7 +135,10 @@ export async function executeAudioGeneration({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
registerPendingNodeIds,
taskContext,
retryContext,
}: CanvasGenerationExecution) {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const isEmptyAudioNode = sourceNode?.type === CanvasNodeType.Audio && !sourceNode.metadata?.content;
@@ -133,15 +154,29 @@ export async function executeAudioGeneration({
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
};
registerPendingNodeIds([audioId]);
setNodes((current) => (isEmptyAudioNode ? current.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode]));
setNodes((current) =>
isEmptyAudioNode ? current.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode],
);
if (!isEmptyAudioNode) setConnections((current) => [...current, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]);
startGenerationRequest(audioId, nodeId, nodeId, controller);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: audioId, mode: "audio", prompt: effectivePrompt, config: generationConfig, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoiceKey: generationContext.resolvedCharacterVoices[0]?.voiceKey }, onTaskCreated: (task) => bindGenerationTask(audioId, task) });
if (!result.audio?.dataUrl) throw new Error("后端任务没有返回音频");
const audio = await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), generationConfig.audioFormat);
setNodes((current) => current.map((node) => (node.id === audioId ? { ...node, metadata: { ...node.metadata, ...audioMetadata(audio), prompt: effectivePrompt, ...buildAudioGenerationMetadata(generationConfig) } } : node)));
await runCanvasGenerationTaskToConsumer(
{
projectId,
nodeId: audioId,
...retryContext,
mode: "audio",
prompt: effectivePrompt,
config: generationConfig,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoiceKey: generationContext.resolvedCharacterVoices[0]?.voiceKey },
},
{
bindTask: (task) => bindGenerationTask(audioId, task),
consumeTask: (task) => applyGenerationTaskResult(audioId, task),
},
);
} finally {
finishGenerationRequest(audioId, controller);
}
@@ -1,7 +1,8 @@
import { Button, Image, Modal } from "antd";
import { TaskDetailItem, taskStatusText } from "./canvas-project-feedback";
import type { GenerationTask, TaskLog } from "@/services/api/task-center";
import { TaskDetailItem } from "./canvas-project-feedback";
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { formatTaskLog, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { VideoPlayer } from "@/components/video-player";
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
@@ -29,18 +30,26 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
{task ? (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<TaskDetailItem label="当前阶段" value={task.stage || taskStatusText(task.status)} />
<TaskDetailItem label="进度" value={`${task.progress ?? 0}%`} />
<TaskDetailItem label="当前阶段" value={generationTaskStageLabel(task)} />
{generationTaskShowsProgress(task) ? <TaskDetailItem label="进度" value={`${task.progress ?? 0}%`} /> : null}
<TaskDetailItem label="模型" value={task.model ? modelDisplayName(config, task.model) : "默认模型"} />
<TaskDetailItem label="任务 ID" value={task.id} />
</div>
<div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>提示词</div>
<div className="max-h-32 overflow-y-auto whitespace-pre-wrap rounded-lg p-3 text-xs leading-5" style={{ background: theme.node.fill }}>{task.prompt || "未记录"}</div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
提示词
</div>
<div className="max-h-32 overflow-y-auto whitespace-pre-wrap rounded-lg p-3 text-xs leading-5" style={{ background: theme.node.fill }}>
{task.prompt || "未记录"}
</div>
</div>
<div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>任务日志</div>
<pre className="max-h-64 overflow-auto rounded-lg bg-neutral-950 p-3 text-[var(--fs-label)] leading-5 text-neutral-100">{taskLoading ? "加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${log.message}`).join("\n") : "暂无日志"}</pre>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
任务日志
</div>
<pre className="max-h-64 overflow-auto rounded-lg bg-neutral-950 p-3 text-[var(--fs-label)] leading-5 text-neutral-100">
{taskLoading ? "加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${formatTaskLog(log)}`).join("\n") : "暂无日志"}
</pre>
</div>
</div>
) : null}
@@ -50,8 +59,18 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
<div className="py-8 text-center text-base font-medium">暂未实现</div>
</Modal>
<Modal title="视频预览" open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)} centered onCancel={onClosePreview} footer={null} width="min(1200px, calc(100vw - 32px))" styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? <VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} className="max-h-[84vh] max-w-full bg-black" /> : null}
<Modal
title="视频预览"
open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)}
centered
onCancel={onClosePreview}
footer={null}
width="min(1200px, calc(100vw - 32px))"
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}
>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? (
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} className="max-h-[84vh] max-w-full bg-black" />
) : null}
</Modal>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (
@@ -75,7 +94,14 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
open={clearConfirmOpen}
centered
onCancel={onCancelClear}
footer={<><Button onClick={onCancelClear}>取消</Button><Button danger type="primary" onClick={onConfirmClear}>清空</Button></>}
footer={
<>
<Button onClick={onCancelClear}>取消</Button>
<Button danger type="primary" onClick={onConfirmClear}>
清空
</Button>
</>
}
>
<p className="text-sm opacity-60">这会删除当前画布上的所有节点和连线。</p>
</Modal>
@@ -1,7 +1,7 @@
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { getGenerationCount, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { getGenerationCount, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -24,9 +24,11 @@ export async function executeTextGeneration({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
registerPendingNodeIds,
taskContext,
retryContext,
}: CanvasGenerationExecution) {
let streamed = "";
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isDirectTextTarget = sourceNode?.type === CanvasNodeType.Text && !sourceNode.metadata?.content?.trim() && !editingTextNode;
const textCount = isConfigNode || (isDirectTextTarget && sourceNode?.metadata?.count) ? getGenerationCount(generationConfig.count) : 1;
@@ -56,29 +58,32 @@ export async function executeTextGeneration({
const textTargetIds = generateInPlace ? [nodeId, ...childIds] : childIds;
textTargetIds.forEach((targetNodeId) => startGenerationRequest(targetNodeId, nodeId, nodeId, controller));
const answers = await Promise.all(
textTargetIds.map((targetNodeId) => {
let localStreamed = "";
return runBackendCanvasGenerationTask({ projectId, nodeId: targetNodeId, mode: "text", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions }, onTaskCreated: (task) => bindGenerationTask(targetNodeId, task) })
.then((result) => {
localStreamed = result.text || "";
streamed = localStreamed;
if (!isConfigNode) setNodes((current) => current.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: localStreamed, richText: undefined, status: NODE_STATUS_LOADING } } : node)));
return { nodeId: targetNodeId, content: localStreamed };
})
.finally(() => finishGenerationRequest(targetNodeId, controller));
}),
);
if (controller.signal.aborted) return;
const answerByNodeId = new Map(answers.map((item) => [item.nodeId, item.content]));
setNodes((current) =>
current.map((node) =>
childIds.includes(node.id)
? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, richText: undefined, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
: node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
: node.id === nodeId && !editingTextNode
? { ...node, type: CanvasNodeType.Text, title: effectivePrompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, richText: undefined, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
: node,
textTargetIds.map((targetNodeId) =>
runCanvasGenerationTaskToConsumer(
{
projectId,
nodeId: targetNodeId,
...retryContext,
mode: "text",
prompt: effectivePrompt,
config: generationConfig,
referenceImages: generationContext.referenceImages,
referenceVideos: generationContext.referenceVideos,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions },
},
{
bindTask: (task) => bindGenerationTask(targetNodeId, task),
consumeTask: (task) => applyGenerationTaskResult(targetNodeId, task),
},
)
.then(() => ({ nodeId: targetNodeId }))
.finally(() => finishGenerationRequest(targetNodeId, controller)),
),
);
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)));
}
}
+196 -72
View File
@@ -42,9 +42,10 @@ export default function CanvasPage() {
const mode = searchParams.get("mode");
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
const agentQuery = agentMode ? `?${searchParams.toString()}` : "";
const handoffMode = mode === "handoff";
const forwardedQuery = agentMode || handoffMode ? `?${searchParams.toString()}` : "";
const enterProject = (id: string) => {
navigate(`/canvas/${id}${agentQuery}`);
navigate(`/canvas/${id}${forwardedQuery}`);
};
const createAndEnter = () => {
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
@@ -56,7 +57,7 @@ export default function CanvasPage() {
const query = keyword.trim().toLowerCase();
const scoped = projects.filter((project) => projectFilter === "all" || (projectFilter === "independent" ? !project.projectId : project.projectId === projectFilter));
const values = query ? scoped.filter((project) => project.title.toLowerCase().includes(query)) : [...scoped];
values.sort((a, b) => sort === "name" ? a.title.localeCompare(b.title, "zh-CN") : sort === "nodes" ? b.nodes.length - a.nodes.length : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
values.sort((a, b) => (sort === "name" ? a.title.localeCompare(b.title, "zh-CN") : sort === "nodes" ? b.nodes.length - a.nodes.length : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
return values;
}, [keyword, projectFilter, projects, sort]);
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
@@ -65,11 +66,7 @@ export default function CanvasPage() {
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布";
const sortLabel = sort === "name" ? "按名称" : sort === "nodes" ? "按节点" : "最近更新";
const projectFilterItems = useMemo(() => [
{ key: "all", label: "全部画布" },
{ key: "independent", label: "自由画布" },
...(projectQuery.data?.projects || []).map(({ project }) => ({ key: project.id, label: project.name })),
], [projectQuery.data]);
const projectFilterItems = useMemo(() => [{ key: "all", label: "全部画布" }, { key: "independent", label: "自由画布" }, ...(projectQuery.data?.projects || []).map(({ project }) => ({ key: project.id, label: project.name }))], [projectQuery.data]);
const sortItems = [
{ key: "updated", label: "最近更新", icon: <Clock3 className="size-3.5" /> },
{ key: "name", label: "按名称", icon: <ArrowDownAZ className="size-3.5" /> },
@@ -103,41 +100,54 @@ export default function CanvasPage() {
}),
),
);
await Promise.all(data.projects.map(async (item) => {
const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"]));
const importedProjectId = importProject({
...item.project,
nodes: item.project.nodes.map((node) => node.type === "drawing" && node.metadata?.drawingId
? { ...node, metadata: { ...node.metadata, drawingEngine: drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" } }
: node),
});
await Promise.all((item.drawingDocuments || []).map((document) => {
const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined;
const preview = previewFile && !previewFile.type ? previewFile.slice(0, previewFile.size, "image/png") : previewFile;
const renderFile = document.generationRender?.path ? zip.get(document.generationRender.path) : undefined;
const renderBlob = renderFile && !renderFile.type ? renderFile.slice(0, renderFile.size, document.generationRender?.mimeType || "image/png") : renderFile;
const render = renderBlob && document.generationRender
? {
blob: renderBlob,
pageId: document.generationRender.pageId,
width: document.generationRender.width,
height: document.generationRender.height,
mimeType: document.generationRender.mimeType,
background: document.generationRender.background,
} satisfies CanvasDrawingRenderDraft
: undefined;
const engine = document.engine || "tldraw";
return saveCanvasDrawing(importedProjectId, document.drawingId, engine, document.snapshot, {
version: 2,
engine,
snapshot: document.snapshot,
revision: Math.max(0, document.revision - 1),
updatedAt: document.updatedAt,
shapeCount: document.shapeCount,
pageCount: document.pageCount,
}, preview, render);
}));
}));
await Promise.all(
data.projects.map(async (item) => {
const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"]));
const importedProjectId = importProject({
...item.project,
nodes: item.project.nodes.map((node) =>
node.type === "drawing" && node.metadata?.drawingId ? { ...node, metadata: { ...node.metadata, drawingEngine: drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" } } : node,
),
});
await Promise.all(
(item.drawingDocuments || []).map((document) => {
const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined;
const preview = previewFile && !previewFile.type ? previewFile.slice(0, previewFile.size, "image/png") : previewFile;
const renderFile = document.generationRender?.path ? zip.get(document.generationRender.path) : undefined;
const renderBlob = renderFile && !renderFile.type ? renderFile.slice(0, renderFile.size, document.generationRender?.mimeType || "image/png") : renderFile;
const render =
renderBlob && document.generationRender
? ({
blob: renderBlob,
pageId: document.generationRender.pageId,
width: document.generationRender.width,
height: document.generationRender.height,
mimeType: document.generationRender.mimeType,
background: document.generationRender.background,
} satisfies CanvasDrawingRenderDraft)
: undefined;
const engine = document.engine || "tldraw";
return saveCanvasDrawing(
importedProjectId,
document.drawingId,
engine,
document.snapshot,
{
version: 2,
engine,
snapshot: document.snapshot,
revision: Math.max(0, document.revision - 1),
updatedAt: document.updatedAt,
shapeCount: document.shapeCount,
pageCount: document.pageCount,
},
preview,
render,
);
}),
);
}),
);
message.success(`已导入 ${data.projects.length} 个画布`);
} catch {
message.error("导入失败,请选择有效的画布压缩包");
@@ -147,7 +157,7 @@ export default function CanvasPage() {
};
useEffect(() => {
if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent")) return;
if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent" && mode !== "handoff")) return;
autoOpenRef.current = true;
if (mode === "recent" && projects[0]?.id) {
enterProject(projects[0].id);
@@ -159,7 +169,7 @@ export default function CanvasPage() {
});
}, [hydrated, message, mode, projects]);
if (hydrated && (mode === "new" || mode === "recent")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
if (hydrated && (mode === "new" || mode === "recent" || mode === "handoff")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
return (
<WorkspacePage grid className="canvas-library-page">
@@ -168,35 +178,102 @@ export default function CanvasPage() {
title="画布"
description="把镜头、素材和想法留在同一张画布里。"
meta={<span className="app-projects-header-meta">{projects.length} 个</span>}
actions={(
actions={
<>
<Button className="library-primary-action !h-9 !px-3.5" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button>
{projects.length ? (
<Dropdown menu={{ items: [{ key: "delete-all", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }] }} trigger={["click"]}>
<Button className="!h-9 !w-9 !p-0" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal className="size-4" />} />
</Dropdown>
) : null}
<Button className="!h-9 !px-3.5" disabled={!hydrated} icon={<FileUp className="size-3.5" />} onClick={() => inputRef.current?.click()}>导入</Button>
<Button className="library-primary-action !h-9 !px-3.5" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>
新建画布
</Button>
{projects.length ? (
<Dropdown menu={{ items: [{ key: "delete-all", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }] }} trigger={["click"]}>
<Button className="!h-9 !w-9 !p-0" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal className="size-4" />} />
</Dropdown>
) : null}
<Button className="!h-9 !px-3.5" disabled={!hydrated} icon={<FileUp className="size-3.5" />} onClick={() => inputRef.current?.click()}>
导入
</Button>
</>
)}
}
/>
<section className="canvas-library-discovery" aria-label="画布浏览工具">
<div className="canvas-library-search">
<Search aria-hidden="true" />
<input value={keyword} placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
{keyword ? <button type="button" aria-label="清除搜索" onClick={() => { setKeyword(""); setPage(1); }}><X /></button> : null}
<input
value={keyword}
placeholder="搜索画布"
aria-label="搜索画布"
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
/>
{keyword ? (
<button
type="button"
aria-label="清除搜索"
onClick={() => {
setKeyword("");
setPage(1);
}}
>
<X />
</button>
) : null}
</div>
<div className="canvas-library-filters">
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: projectFilterItems, selectedKeys: [projectFilter], onClick: ({ key }) => { setProjectFilter(String(key)); setPage(1); } }}>
<button type="button" className={`canvas-library-filter${projectFilter !== "all" ? " is-active" : ""}`} aria-label="按所属项目筛选"><SlidersHorizontal /><span>{projectFilterLabel}</span></button>
<Dropdown
trigger={["click"]}
placement="bottomLeft"
menu={{
items: projectFilterItems,
selectedKeys: [projectFilter],
onClick: ({ key }) => {
setProjectFilter(String(key));
setPage(1);
},
}}
>
<button type="button" className={`canvas-library-filter${projectFilter !== "all" ? " is-active" : ""}`} aria-label="按所属项目筛选">
<SlidersHorizontal />
<span>{projectFilterLabel}</span>
</button>
</Dropdown>
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: sortItems, selectedKeys: [sort], onClick: ({ key }) => { setSort(key as typeof sort); setPage(1); } }}>
<button type="button" className={`canvas-library-filter${sort !== "updated" ? " is-active" : ""}`} aria-label="画布排序">{sort === "updated" ? <Clock3 /> : sort === "name" ? <ArrowDownAZ /> : <ListFilter />}<span>{sortLabel}</span></button>
<Dropdown
trigger={["click"]}
placement="bottomLeft"
menu={{
items: sortItems,
selectedKeys: [sort],
onClick: ({ key }) => {
setSort(key as typeof sort);
setPage(1);
},
}}
>
<button type="button" className={`canvas-library-filter${sort !== "updated" ? " is-active" : ""}`} aria-label="画布排序">
{sort === "updated" ? <Clock3 /> : sort === "name" ? <ArrowDownAZ /> : <ListFilter />}
<span>{sortLabel}</span>
</button>
</Dropdown>
{keyword || projectFilter !== "all" || sort !== "updated" ? <button type="button" className="canvas-library-reset" onClick={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); setPage(1); }}>重置</button> : null}
{keyword || projectFilter !== "all" || sort !== "updated" ? (
<button
type="button"
className="canvas-library-reset"
onClick={() => {
setKeyword("");
setProjectFilter("all");
setSort("updated");
setPage(1);
}}
>
重置
</button>
) : null}
</div>
<span className="canvas-library-count"><strong>{String(filteredProjects.length).padStart(2, "0")}</strong><span>/ {String(projects.length).padStart(2, "0")} 画布</span></span>
<span className="canvas-library-count">
<strong>{String(filteredProjects.length).padStart(2, "0")}</strong>
<span>/ {String(projects.length).padStart(2, "0")} 画布</span>
</span>
</section>
</div>
@@ -204,10 +281,34 @@ export default function CanvasPage() {
{selectedIds.length ? (
<div className="app-canvas-selection-toolbar mt-2 flex min-h-10 flex-wrap items-center gap-2 rounded-md border px-3 py-1.5 text-xs">
<strong className="mr-auto font-medium">已选 {selectedIds.length} 个画布</strong>
<Button size="small" disabled={!hydrated || projectQuery.isLoading} onClick={() => { setAssociationProjectId(selectedProjects[0]?.projectId || ""); setAssociationOpen(true); }}>加入项目</Button>
{selectedProjects.some((project) => project.projectId) ? <Button size="small" disabled={!hydrated} onClick={() => { setAssociationProjectId(""); void associateSelected(""); }}>移出项目</Button> : null}
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(selectedProjects, `影策画布-${selectedIds.length}个画布`)}>导出</Button>
<Button size="small" danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>删除</Button>
<Button
size="small"
disabled={!hydrated || projectQuery.isLoading}
onClick={() => {
setAssociationProjectId(selectedProjects[0]?.projectId || "");
setAssociationOpen(true);
}}
>
加入项目
</Button>
{selectedProjects.some((project) => project.projectId) ? (
<Button
size="small"
disabled={!hydrated}
onClick={() => {
setAssociationProjectId("");
void associateSelected("");
}}
>
移出项目
</Button>
) : null}
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(selectedProjects, `影策画布-${selectedIds.length}个画布`)}>
导出
</Button>
<Button size="small" danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
删除
</Button>
</div>
) : null}
@@ -224,14 +325,37 @@ export default function CanvasPage() {
<WorkspaceState icon="canvas" title="没有匹配的画布" description="换一个画布名称或重置筛选条件。" />
)}
<PaginationBar current={page} pageSize={pageSize} total={filteredProjects.length} pageSizeOptions={[12, 24, 48]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
<PaginationBar
current={page}
pageSize={pageSize}
total={filteredProjects.length}
pageSizeOptions={[12, 24, 48]}
onChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage);
setPageSize(nextPageSize);
}}
/>
</div>
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<Modal title="加入项目" open={associationOpen} okText="保存关联" cancelText="取消" okButtonProps={{ disabled: !associationProjectId, loading: projectQuery.isFetching }} onCancel={() => setAssociationOpen(false)} onOk={() => void associateSelected()}>
<p className="mb-3 text-sm text-foreground/60">选中的画布会保留原有节点和本地媒体,只增加项目关联。</p>
<Select className="w-full" value={associationProjectId || undefined} placeholder="选择项目" options={(projectQuery.data?.projects || []).map((item) => ({ label: item.project.name, value: item.project.id }))} onChange={setAssociationProjectId} />
</Modal>
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<Modal
title="加入项目"
open={associationOpen}
okText="保存关联"
cancelText="取消"
okButtonProps={{ disabled: !associationProjectId, loading: projectQuery.isFetching }}
onCancel={() => setAssociationOpen(false)}
onOk={() => void associateSelected()}
>
<p className="mb-3 text-sm text-foreground/60">选中的画布会保留原有节点和本地媒体,只增加项目关联。</p>
<Select
className="w-full"
value={associationProjectId || undefined}
placeholder="选择项目"
options={(projectQuery.data?.projects || []).map((item) => ({ label: item.project.name, value: item.project.id }))}
onChange={setAssociationProjectId}
/>
</Modal>
</WorkspacePage>
);
}
+86 -11
View File
@@ -4,6 +4,9 @@ import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadMediaFile } from "@/services/file-storage";
import { readLocalRuntimeBootstrapState } from "@/services/local-runtime-bootstrap";
import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
import { getActiveUserScope } from "@/lib/user-scope";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
@@ -11,7 +14,9 @@ import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
import { shouldAutoConnectCanvasRuntime } from "@/lib/canvas/local-runtime-connection";
import { useAssetStore } from "@/stores/use-asset-store";
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { App } from "antd";
@@ -60,6 +65,7 @@ import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
@@ -82,7 +88,7 @@ import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
import { useCanvasDirector } from "./use-canvas-director";
import { useCanvasGeneration } from "./use-canvas-generation";
import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
import { useCanvasGenerationExecutor } from "./use-canvas-generation-executor";
import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
import { useCanvasGenerationRetry } from "./use-canvas-generation-retry";
import { useCanvasHistory } from "./use-canvas-history";
import { useCanvasKeyboard } from "./use-canvas-keyboard";
@@ -143,19 +149,22 @@ export default function CanvasPage() {
function InfiniteCanvasPage() {
const { message } = App.useApp();
const params = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const projectId = params.id || "";
const canvasStorageScope = getActiveUserScope();
const localAgentConnected = useCanvasAgentStore((state) => state.connected);
const localAgentActivity = useCanvasAgentStore((state) => state.activity);
const localAgentEnabled = useCanvasAgentStore((state) => state.enabled);
const containerRef = useRef<HTMLDivElement>(null);
const didInitialCenterRef = useRef(false);
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const assetHandoffRef = useRef("");
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const assets = useAssetStore((state) => state.assets);
const assetsHydrated = useAssetStore((state) => state.hydrated);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
@@ -199,8 +208,8 @@ function InfiniteCanvasPage() {
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
const codexAutoConnect = ["new", "recent", "choose"].includes(searchParams.get("mode") || "");
const codexCompactAgent = codexAutoConnect && searchParams.has("agentUrl");
const codexAutoConnect = shouldAutoConnectCanvasRuntime(searchParams);
const codexCompactAgent = codexAutoConnect && readLocalRuntimeBootstrapState().legacyDeepLinkRejected;
const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
@@ -253,9 +262,20 @@ function InfiniteCanvasPage() {
const nodesRef = useRef(nodes);
const connectionsRef = useRef(connections);
const chatSessionsRef = useRef(chatSessions);
const activeChatIdRef = useRef(activeChatId);
const selectedNodeIdsRef = useRef(selectedNodeIds);
const viewportRef = useRef(viewport);
const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise<void>) | null>(null);
const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise<void>) | null>(null);
useEffect(() => {
if (!projectId) return;
return registerCanvasGenerationLiveProject({
scope: canvasStorageScope,
projectId,
adapter: createCanvasGenerationLiveProjectAdapter({ nodesRef, connectionsRef, chatSessionsRef, activeChatIdRef, setNodes, setConnections, setChatSessions, setActiveChatId }),
});
}, [canvasStorageScope, projectId]);
const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({
projectLoaded,
@@ -318,17 +338,30 @@ function InfiniteCanvasPage() {
}, [linkedProjectQuery.data, projectLoaded, setNodes]);
const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]);
const { bindGenerationTask, cancelNodeTask, confirmStopGeneration, finishGenerationRequest, openNodeTaskDetails, runningNodeId, setRunningNodeId, setTaskDetail, startGenerationRequest, taskDetail, taskDetailLoading, taskDetailLogs } =
useCanvasGeneration({ projectId, domainProjectId: linkedProjectId, projectLoaded, nodes, nodesRef, setNodes });
const {
applyGenerationTaskResult,
bindGenerationTask,
cancelNodeTask,
confirmStopGeneration,
finishGenerationRequest,
openNodeTaskDetails,
runningNodeId,
setRunningNodeId,
setTaskDetail,
startGenerationRequest,
taskDetail,
taskDetailLoading,
taskDetailLogs,
} = useCanvasGeneration({ projectId, domainProjectId: linkedProjectId, projectLoaded, nodes, nodesRef, setNodes });
useEffect(() => {
if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return;
if (searchParams.has("agentUrl")) {
if (!projectLoaded || !codexAutoConnect) return;
if (codexCompactAgent) {
setAgentMode("local");
return;
}
openAgent("local");
}, [openAgent, projectLoaded, searchParams, setAgentMode]);
}, [codexAutoConnect, codexCompactAgent, openAgent, projectLoaded, setAgentMode]);
// 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。
const prevFocusModeRef = useRef(focusMode);
@@ -348,9 +381,11 @@ function InfiniteCanvasPage() {
useLayoutEffect(() => {
nodesRef.current = nodes;
connectionsRef.current = connections;
chatSessionsRef.current = chatSessions;
activeChatIdRef.current = activeChatId;
selectedNodeIdsRef.current = selectedNodeIds;
viewportRef.current = viewport;
}, [nodes, connections, selectedNodeIds, viewport]);
}, [activeChatId, chatSessions, nodes, connections, selectedNodeIds, viewport]);
useEffect(() => {
if (!projectLoaded) return;
@@ -477,6 +512,42 @@ function InfiniteCanvasPage() {
setDialogNodeId,
});
useEffect(() => {
if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return;
const attempt = canvasAssetHandoffAttempt(assets, searchParams);
const { assetIds, payloads } = attempt;
if (!assetIds.length) return;
const assetReadiness = assetIds
.map((assetId) => {
const asset = assets.find((candidate) => candidate.id === assetId);
return `${assetId}:${asset?.kind || "missing"}`;
})
.join("|");
const handoffKey = `${projectId}:${assetReadiness}`;
if (assetHandoffRef.current === handoffKey) return;
assetHandoffRef.current = handoffKey;
if (attempt.kind === "retry") return;
const pendingPayloads = uninsertedCanvasAssetHandoffPayloads(nodesRef.current, payloads);
const persistHandoff = async (createdNodes: CanvasNodeData[]) => {
const finalized = await finalizeCanvasAssetHandoff({
searchParams,
currentNodes: nodesRef.current,
createdNodes,
persist: async (nextNodes) => {
nodesRef.current = nextNodes;
updateProject(projectId, { nodes: nextNodes });
await flushCanvasStorePersistence();
},
});
setSearchParams(finalized.searchParams, { replace: true });
};
const insertion = pendingPayloads.length ? handleProjectAssetsInsert(pendingPayloads) : Promise.resolve([] as CanvasNodeData[]);
void insertion.then(persistHandoff).catch(() => {
assetHandoffRef.current = "";
});
}, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]);
// 时间线弹窗内新增素材的回填通道:素材库/上传创建节点后由弹窗通过 ref 加入草稿。
const timelineAddNodeRef = useRef<((node: CanvasNodeData) => void) | null>(null);
// 时间线作用域直连媒体入轨通道:素材库/项目资产/本地上传不落画布,仅加入时间线草稿。
@@ -1063,6 +1134,8 @@ function InfiniteCanvasPage() {
});
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
chatSessionsRef.current = sessions;
activeChatIdRef.current = activeId;
setChatSessions(sessions);
setActiveChatId(activeId);
}, []);
@@ -1193,6 +1266,7 @@ function InfiniteCanvasPage() {
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
});
useEffect(() => {
generateNodeRef.current = handleGenerateNode;
@@ -1230,6 +1304,7 @@ function InfiniteCanvasPage() {
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
});
const generateImageFromTextNode = useCallback(
@@ -2,8 +2,30 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { createGenerationRetryContext, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { persistCanvasAgentGenerationContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
export type CanvasAgentGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasAgentRetryContext = GenerationRetryContext;
type CanvasAgentGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasAgentGenerationOptions = {
context?: CanvasAgentGenerationContext;
retryContext?: CanvasAgentRetryContext;
onTaskUpdate?: (task: GenerationTask) => void;
};
type CanvasAgentGenerationContinuationDependencies = {
consumeAgent?: typeof consumeGenerationTaskAgent;
persistContinuation?: typeof persistCanvasAgentGenerationContinuationEffect;
projectId?: string;
nodeId?: string;
previousNodes?: CanvasNodeData[];
nodesRef?: { current: CanvasNodeData[] };
setNodes?: Dispatch<SetStateAction<CanvasNodeData[]>>;
};
type UseCanvasAgentOperationsOptions = {
projectId: string;
domainProjectId?: string;
@@ -16,7 +38,7 @@ type UseCanvasAgentOperationsOptions = {
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set<string> };
viewportRef: { current: ViewportTransform };
generateNodeRef: { current: ((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise<void>) | null };
generateNodeRef: { current: ((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasAgentGenerationOptions) => Promise<void>) | null };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
@@ -35,6 +57,159 @@ export type CanvasAgentChange = {
type CanvasAgentUndoBatch = { snapshot: CanvasAgentSnapshot; afterNodes: CanvasNodeData[]; afterConnections: CanvasConnection[]; change: Omit<CanvasAgentChange, "undoCount"> };
type RunCanvasAgentGenerationOpsInput = {
generationOps: Array<Extract<CanvasAgentOp, { type: "run_generation" }>>;
nodes: CanvasNodeData[];
context?: CanvasAgentGenerationContext;
generate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options: CanvasAgentGenerationOptions) => Promise<void>;
subscribeTasks?: (ids: readonly string[], listener: (task: GenerationTask) => void) => () => void;
consumeTask?: typeof consumeGenerationTaskAgent;
resumeAgent?: (task: GenerationTask) => Promise<void>;
onContinuation?: (nodeId: string, continuation: CanvasAgentGenerationContinuation) => Promise<void> | void;
};
export async function runCanvasAgentGenerationOps({
generationOps,
nodes,
context,
generate,
subscribeTasks = subscribeGenerationTasks,
consumeTask = consumeGenerationTaskAgent,
resumeAgent = async () => undefined,
onContinuation,
}: RunCanvasAgentGenerationOpsInput) {
const observations = new Map<string, Promise<void>>();
const observe = (taskId: string, nodeId: string, continuationIdPromise?: Promise<string>) => {
const existing = observations.get(taskId);
if (existing) return existing;
const observation = new Promise<void>((resolve, reject) => {
let unsubscribe: (() => void) | undefined;
let settled = false;
unsubscribe = subscribeTasks([taskId], (task) => {
if (settled || (task.status !== "succeeded" && task.status !== "failed" && task.status !== "cancelled")) return;
settled = true;
queueMicrotask(() => {
void (async () => {
const taskContext = {
conversationId: task.clientContext?.conversationId || context?.conversationId,
messageId: task.clientContext?.messageId || context?.messageId,
};
const continuationId = await (continuationIdPromise ?? canvasAgentGenerationContinuationId(task.clientContext?.nodeId || nodeId, taskContext));
const continuation: CanvasAgentGenerationContinuation = {
id: continuationId,
taskId: task.id,
...(taskContext.conversationId ? { conversationId: taskContext.conversationId } : {}),
...(taskContext.messageId ? { messageId: taskContext.messageId } : {}),
...(context?.source ? { source: context.source } : {}),
status: task.status === "succeeded" ? "pending" : "failed",
};
if (task.status !== "succeeded") {
await onContinuation?.(nodeId, continuation);
await resumeAgent(task);
return;
}
await consumeTask(task, continuationId, async (effect?: { effectKey?: string }) => {
await onContinuation?.(nodeId, {
...continuation,
status: "completed",
...(effect?.effectKey ? { effectKey: effect.effectKey } : {}),
});
await resumeAgent(task);
});
})()
.then(resolve, reject)
.finally(() => unsubscribe?.());
});
});
});
observations.set(taskId, observation);
return observation;
};
if (!generationOps.length) {
const taskNodes = nodes.filter((node) => node.metadata?.agentGenerationContinuation?.status === "pending" && (node.metadata?.taskId || node.metadata.agentGenerationContinuation.taskId));
await Promise.all(
taskNodes.map((node) => {
const continuation = node.metadata!.agentGenerationContinuation!;
return observe(node.metadata?.taskId || continuation.taskId, node.id, Promise.resolve(continuation.id));
}),
);
return;
}
await Promise.all(
generationOps.map(async (op) => {
const target = nodes.find((node) => node.id === op.nodeId);
const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
const retryOf = op.retry ? target?.metadata?.taskId : undefined;
if (op.retry && !retryOf) throw new Error("当前节点没有可重试的生成任务");
const retryContext = retryOf ? await createGenerationRetryContext(retryOf, target?.metadata?.attemptGroupId) : undefined;
const continuationIdPromise = canvasAgentGenerationContinuationId(op.nodeId, context);
let observation: Promise<void> | undefined;
let continuationTaskId = "";
const generationPromise = generate(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt, {
context: context ? { conversationId: context.conversationId, messageId: context.messageId } : undefined,
...(retryContext ? { retryContext } : {}),
onTaskUpdate: (task) => {
if (continuationTaskId) return;
continuationTaskId = task.id;
observation = observe(task.id, op.nodeId, continuationIdPromise);
void continuationIdPromise.then((continuationId) => {
onContinuation?.(op.nodeId, {
id: continuationId,
taskId: task.id,
...(context?.conversationId ? { conversationId: context.conversationId } : {}),
...(context?.messageId ? { messageId: context.messageId } : {}),
...(context?.source ? { source: context.source } : {}),
status: "pending",
});
});
},
});
await generationPromise;
if (observation) await observation;
}),
);
}
export async function consumeCanvasAgentGenerationContinuation(
task: GenerationTask,
continuation: CanvasAgentGenerationContinuation,
onCompleted: (continuation: CanvasAgentGenerationContinuation, signal?: AbortSignal) => Promise<void> | void,
dependencies: CanvasAgentGenerationContinuationDependencies = {},
signal?: AbortSignal,
) {
if (continuation.status !== "pending" || continuation.taskId !== task.id || task.status !== "succeeded") return task;
const consumeAgent = dependencies.consumeAgent ?? consumeGenerationTaskAgent;
return consumeAgent(
task,
continuation.id,
async ({ effectKey, signal: leaseSignal }) => {
const completed = { ...continuation, status: "completed" as const, effectKey };
if (dependencies.projectId && dependencies.nodeId && dependencies.nodesRef && dependencies.setNodes) {
await (dependencies.persistContinuation ?? persistCanvasAgentGenerationContinuationEffect)({
projectId: dependencies.projectId,
nodeId: dependencies.nodeId,
continuation: completed,
effectKey,
signal: leaseSignal,
previousNodes: dependencies.previousNodes,
nodesRef: dependencies.nodesRef,
setNodes: dependencies.setNodes,
});
}
await onCompleted(completed, leaseSignal);
},
{ signal },
);
}
async function canvasAgentGenerationContinuationId(nodeId: string, context?: CanvasAgentGenerationContext) {
const seed = new TextEncoder().encode(`canvas-agent-generation\0${context?.source || ""}\0${context?.conversationId || ""}\0${context?.messageId || ""}\0${nodeId}`);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", seed));
return `agent:${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
export function useCanvasAgentOperations({
projectId,
domainProjectId,
@@ -79,44 +254,81 @@ export function useCanvasAgentOperations({
setLastAgentChange(null);
}, [connections, nodes]);
const applyOps = useCallback((ops?: CanvasAgentOp[]) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, domainProjectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation"));
const beforeNodeIds = new Set(before.nodes.map((node) => node.id));
const addedNodeIds = next.nodes.filter((node) => !beforeNodeIds.has(node.id)).map((node) => node.id);
const addedNodeIdSet = new Set(addedNodeIds);
const focusNodeIds = next.nodes.filter((node) => addedNodeIdSet.has(node.id) && (!node.parentId || !addedNodeIdSet.has(node.parentId))).map((node) => node.id);
const affectedNodeIds = focusNodeIds.length ? focusNodeIds : agentAffectedNodeIds(safeOps, next.nodes);
const nextSelectedNodeIds = focusNodeIds.length ? focusNodeIds : next.selectedNodeIds;
nodesRef.current = next.nodes;
connectionsRef.current = next.connections;
selectedNodeIdsRef.current = new Set(nextSelectedNodeIds);
viewportRef.current = next.viewport;
setNodes(next.nodes);
setConnections(next.connections);
setSelectedNodeIds(new Set(nextSelectedNodeIds));
setSelectedConnectionId(null);
setViewport(next.viewport);
setContextMenu(null);
if (safeOps.length) {
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasAgentOps(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
undoStackRef.current = [...undoStackRef.current, { snapshot: before, afterNodes: next.nodes, afterConnections: next.connections, change }].slice(-10);
const nextUndoCount = undoStackRef.current.length;
setUndoOpsCount(nextUndoCount);
setLastAgentChange({ ...change, undoCount: nextUndoCount });
}
if (focusNodeIds.length) queueMicrotask(() => focusSelection());
if (generationOps.length) {
queueMicrotask(() => generationOps.forEach((op) => {
const target = nodesRef.current.find((node) => node.id === op.nodeId);
const prompt = op.prompt?.trim() ? op.prompt : target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "";
void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt);
}));
}
return { ...next, projectId, title: projectTitle, selectedNodeIds: nextSelectedNodeIds };
}, [connectionsRef, domainProjectId, focusSelection, generateNodeRef, nodesRef, projectId, projectTitle, selectedNodeIdsRef, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setViewport, viewportRef]);
const applyOps = useCallback(
async (ops?: CanvasAgentOp[], generationContext?: CanvasAgentGenerationContext) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, domainProjectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(
before,
safeOps.filter((op) => op.type !== "run_generation"),
);
const beforeNodeIds = new Set(before.nodes.map((node) => node.id));
const addedNodeIds = next.nodes.filter((node) => !beforeNodeIds.has(node.id)).map((node) => node.id);
const addedNodeIdSet = new Set(addedNodeIds);
const focusNodeIds = next.nodes.filter((node) => addedNodeIdSet.has(node.id) && (!node.parentId || !addedNodeIdSet.has(node.parentId))).map((node) => node.id);
const affectedNodeIds = focusNodeIds.length ? focusNodeIds : agentAffectedNodeIds(safeOps, next.nodes);
const nextSelectedNodeIds = focusNodeIds.length ? focusNodeIds : next.selectedNodeIds;
nodesRef.current = next.nodes;
connectionsRef.current = next.connections;
selectedNodeIdsRef.current = new Set(nextSelectedNodeIds);
viewportRef.current = next.viewport;
setNodes(next.nodes);
setConnections(next.connections);
setSelectedNodeIds(new Set(nextSelectedNodeIds));
setSelectedConnectionId(null);
setViewport(next.viewport);
setContextMenu(null);
if (safeOps.length) {
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasAgentOps(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
undoStackRef.current = [...undoStackRef.current, { snapshot: before, afterNodes: next.nodes, afterConnections: next.connections, change }].slice(-10);
const nextUndoCount = undoStackRef.current.length;
setUndoOpsCount(nextUndoCount);
setLastAgentChange({ ...change, undoCount: nextUndoCount });
}
if (focusNodeIds.length) queueMicrotask(() => focusSelection());
if (generationOps.length) {
const generate = generateNodeRef.current;
if (generate)
await runCanvasAgentGenerationOps({
generationOps,
nodes: nodesRef.current,
generate,
context: generationContext,
onContinuation: async (nodeId, continuation) => {
if (continuation.status === "completed" && continuation.effectKey) {
await persistCanvasAgentGenerationContinuationEffect({
projectId,
nodeId,
continuation,
effectKey: continuation.effectKey,
nodesRef,
setNodes,
});
return;
}
setNodes((current) => {
const updated = current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
agentGenerationContinuation: continuation,
},
}
: node,
);
nodesRef.current = updated;
return updated;
});
},
});
}
return { ...next, nodes: nodesRef.current, connections: connectionsRef.current, projectId, title: projectTitle, selectedNodeIds: nextSelectedNodeIds };
},
[connectionsRef, domainProjectId, focusSelection, generateNodeRef, nodesRef, projectId, projectTitle, selectedNodeIdsRef, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setViewport, viewportRef],
);
const undoOps = useCallback(() => {
const batch = undoStackRef.current.at(-1);
@@ -3,9 +3,10 @@ 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 { buildGenerationConfig, createGenerationRetryContext, generationTaskMetadata, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { unchangedModeratedPrompt } from "@/lib/generation-error";
import { cancelGenerationTask, listGenerationTasks } from "@/services/api/task-center";
import { localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchMode, CanvasNodeData } from "@/types/canvas";
@@ -35,54 +36,64 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
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;
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 ? { ...node, metadata: { ...node.metadata, generationBatches: batches } } : node;
return changed ? next : current;
});
return changed ? next : current;
});
}, [setNodes]);
},
[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 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) => {
@@ -148,12 +159,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
const currentNodes = nodesRef.current;
// 没有当前画布的等待项时无需查询任务中心,避免空画布持续轮询。
const hasWaitingItems = currentNodes.some((sourceNode) =>
(sourceNode.metadata?.generationBatches || []).some((batch) =>
batch.projectId === projectId &&
batch.status !== "completed" &&
batch.status !== "cancelled" &&
batch.items.some((item) => item.status === "waiting"),
),
(sourceNode.metadata?.generationBatches || []).some((batch) => batch.projectId === projectId && batch.status !== "completed" && batch.status !== "cancelled" && batch.items.some((item) => item.status === "waiting")),
);
if (!hasWaitingItems) return;
@@ -163,7 +169,10 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
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);
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);
@@ -202,7 +211,14 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
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(() => {
void handleGenerateNode(node.id, generationMode, prompt, {
controller,
waitForTaskCapacity: true,
retryContext:
node.metadata?.retryOf && node.metadata.attemptGroupId && node.metadata.taskClientOperationId
? { retryOf: node.metadata.retryOf, attemptGroupId: node.metadata.attemptGroupId, clientOperationId: node.metadata.taskClientOperationId }
: undefined,
}).finally(() => {
controllersRef.current.delete(key);
reconcileBatches();
});
@@ -212,104 +228,165 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
}
}, [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() };
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 = async () => {
const retryContexts = new Map<string, Awaited<ReturnType<typeof createGenerationRetryContext>>>();
await Promise.all(
retryableItems.map(async (item) => {
const retryNode = nodeById.get(item.nodeId);
if (!retryNode?.metadata?.taskId) return;
retryContexts.set(item.nodeId, await createGenerationRetryContext(retryNode.metadata.taskId, retryNode.metadata.attemptGroupId));
}),
);
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;
const retryContext = retryContexts.get(node.id);
return {
...node,
metadata: {
...resetGenerationTaskMetadata(node.metadata),
...(retryContext
? {
retryOf: retryContext.retryOf,
attemptGroupId: retryContext.attemptGroupId,
taskClientOperationId: retryContext.clientOperationId,
}
: {}),
},
};
}),
);
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) };
});
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]);
},
[message, modal, nodesRef, updateBatch],
);
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]);
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;
const cancellationCopy = localDreaminaCancellationCopy({
id: taskId,
status: node?.metadata?.taskStatus === "queued" ? "queued" : "running",
stage: node?.metadata?.taskStage,
receiptRecorded: node?.metadata?.taskReceiptRecorded,
});
modal.confirm({
title: "取消这个后台任务?",
content: cancellationCopy?.confirmation || "任务会在后端停止,已经生成完成的其他镜头不会受影响。",
okText: cancellationCopy?.action || "取消任务",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
try {
const task = await cancelGenerationTask(taskId);
const outcome = localDreaminaDetachOutcome(task);
const stopMessage = outcome?.message ?? localDreaminaCancellationMessage(task);
if (outcome?.kind !== "background") 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: outcome?.canvasNodeStatus ?? "error",
errorDetails: outcome?.kind === "background" ? undefined : stopMessage,
},
};
if (currentNode.id !== sourceNodeId) return currentNode;
const batches = (currentNode.metadata?.generationBatches || []).map((currentBatch) =>
currentBatch.id === batchId
? withUpdatedItem(currentBatch, item.id, {
status: outcome?.batchItemStatus ?? "cancelled",
taskId,
errorDetails: outcome?.kind === "background" ? undefined : stopMessage,
})
: 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;
@@ -343,7 +420,7 @@ function itemChanged(item: CanvasGenerationBatchItem, patch: Partial<CanvasGener
}
function withUpdatedItem(batch: CanvasGenerationBatch, itemId: string, patch: Partial<CanvasGenerationBatchItem>) {
const items = batch.items.map((item) => item.id === itemId ? { ...item, ...patch } : item);
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) };
}
@@ -37,6 +37,7 @@ type UseCanvasGenerationExecutorOptions = {
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
applyGenerationTaskResult: (targetNodeId: string, task: GenerationTask) => Promise<void>;
};
const NODE_STATUS_IDLE = "idle" as const;
@@ -46,6 +47,9 @@ const NODE_STATUS_ERROR = "error" as const;
export type CanvasNodeGenerationOptions = {
controller?: AbortController;
waitForTaskCapacity?: boolean;
context?: { conversationId?: string; messageId?: string };
retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string };
onTaskUpdate?: (task: GenerationTask) => void;
};
export function useCanvasGenerationExecutor({
@@ -64,6 +68,7 @@ export function useCanvasGenerationExecutor({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasGenerationExecutorOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
@@ -80,16 +85,18 @@ export function useCanvasGenerationExecutor({
const hasLiveBatchChildren = sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id));
const hasStaleImageBatchState = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren;
if (hasStaleImageBatchState) {
setNodes((current) => current.map((node) => {
if (node.id !== sourceNode.id) return node;
const metadata = { ...node.metadata };
delete metadata.isBatchRoot;
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;
delete metadata.batchUsesReferenceImages;
return { ...node, metadata };
}));
setNodes((current) =>
current.map((node) => {
if (node.id !== sourceNode.id) return node;
const metadata = { ...node.metadata };
delete metadata.isBatchRoot;
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;
delete metadata.batchUsesReferenceImages;
return { ...node, metadata };
}),
);
}
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
@@ -100,9 +107,7 @@ export function useCanvasGenerationExecutor({
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture
? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture)
: prompt;
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
if (isPreparingEmptyImage) {
setNodes((current) =>
@@ -137,15 +142,7 @@ export function useCanvasGenerationExecutor({
const compatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前逻辑模型没有可用的细分模型:${compatibilityError}`);
const referenceLimits = modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(
baseContext,
projectId,
domainProjectId,
mode,
mode === "video" && Boolean(referenceLimits?.maxAudios),
!promptOnly,
referenceLimits,
);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig.videoSeconds);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
@@ -153,7 +150,23 @@ export function useCanvasGenerationExecutor({
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage) {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, errorDetails: controller.signal.aborted ? undefined : errorDetails } } : node)));
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR,
taskStage: undefined,
taskProgress: undefined,
taskCreatedAt: undefined,
errorDetails: controller.signal.aborted ? undefined : errorDetails,
},
}
: node,
),
);
}
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
@@ -173,7 +186,10 @@ export function useCanvasGenerationExecutor({
}
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage) setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, errorDetails } } : node)));
if (isPreparingEmptyImage)
setNodes((current) =>
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, errorDetails } } : node)),
);
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error(errorDetails);
@@ -198,7 +214,8 @@ export function useCanvasGenerationExecutor({
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (controller.signal.aborted) {
if (isPreparingEmptyImage) setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined } } : node)));
if (isPreparingEmptyImage)
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined } } : node)));
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
@@ -212,7 +229,12 @@ export function useCanvasGenerationExecutor({
setRunningNodeId(null);
return;
}
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)));
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,
),
);
let pendingNodeIds: string[] = [];
const execution = {
@@ -228,6 +250,8 @@ export function useCanvasGenerationExecutor({
controller,
editingTextNode,
styleMetadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,
setConnections,
setSelectedNodeIds,
@@ -235,7 +259,11 @@ export function useCanvasGenerationExecutor({
setDialogNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => {
bindGenerationTask(targetNodeId, task);
options?.onTaskUpdate?.(task);
},
applyGenerationTaskResult,
showError: (content: string) => message.error(content),
registerPendingNodeIds: (nodeIds: string[]) => {
pendingNodeIds = nodeIds;
@@ -251,31 +279,60 @@ export function useCanvasGenerationExecutor({
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, prompt);
if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) {
setNodes((current) => current.map((node) => {
if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node;
const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined };
delete metadata.taskId;
delete metadata.taskStatus;
delete metadata.taskProgress;
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
return { ...node, metadata };
}));
setNodes((current) =>
current.map((node) => {
if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node;
const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined };
delete metadata.taskId;
delete metadata.taskStatus;
delete metadata.taskProgress;
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
return { ...node, metadata };
}),
);
return;
}
message.error(failure.errorDetails);
setNodes((current) => current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)));
setNodes((current) =>
current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)),
);
} finally {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
}
},
[addedSkills, assets, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
[
addedSkills,
applyGenerationTaskResult,
bindGenerationTask,
domainProjectId,
effectiveConfig,
finishGenerationRequest,
isAiConfigReady,
message,
nodesRef,
connectionsRef,
projectId,
setConnections,
setDialogNodeId,
setNodes,
setRunningNodeId,
setSelectedConnectionId,
setSelectedNodeIds,
startGenerationRequest,
],
);
}
function generationModelRequirements(mode: CanvasNodeGenerationMode, input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">, sourceNode: CanvasNodeData | undefined, videoSeconds: string, includeCharacterMinimum = false): ModelRequirements {
function generationModelRequirements(
mode: CanvasNodeGenerationMode,
input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">,
sourceNode: CanvasNodeData | undefined,
videoSeconds: string,
includeCharacterMinimum = false,
): ModelRequirements {
return {
capability: mode,
input: {
@@ -3,15 +3,13 @@ import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { buildEmotionImageArtifacts, compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
import { buildEmotionImageArtifacts, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
import {
buildAudioGenerationMetadata,
buildGenerationConfig,
buildImageGenerationMetadata,
buildVideoGenerationMetadata,
createGenerationRetryContext,
findRetrySourceNode,
generationReferenceUrls,
isGenerationCanceled,
@@ -19,6 +17,7 @@ import {
resolveMetadataReferences,
resolveStoredReferenceImages,
runBackendCanvasGenerationTask,
runCanvasGenerationTaskToConsumer,
sourceNodeReferenceImages,
supportsVideoReferenceAudio,
} from "@/lib/canvas/canvas-project-generation";
@@ -27,11 +26,9 @@ import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video";
import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { Asset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
@@ -48,13 +45,27 @@ type UseCanvasGenerationRetryOptions = {
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
applyGenerationTaskResult: (targetNodeId: string, task: GenerationTask) => Promise<void>;
};
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkills, assets, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
export function useCanvasGenerationRetry({
projectId,
domainProjectId,
addedSkills,
assets,
nodesRef,
connectionsRef,
setNodes,
setRunningNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasGenerationRetryOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
@@ -72,7 +83,14 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
let generationConfig =
hasSavedImageMetadata && savedImageMetadata
? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground) === "true" ? "true" : "false", count: "1" }
? {
...effectiveConfig,
model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model,
quality: savedImageMetadata.quality || effectiveConfig.quality,
size: savedImageMetadata.size || effectiveConfig.size,
transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground) === "true" ? "true" : "false",
count: "1",
}
: { ...buildGenerationConfig(effectiveConfig, sourceNode, retryMode), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
@@ -80,9 +98,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
}
const retryPromptSource = sourceNode.metadata?.composerContent || sourceNode.metadata?.prompt || node.metadata?.prompt || "";
const retryContextPrompt = retryMode === "image" && sourceNode.metadata?.portraitTexture
? buildPortraitTexturePrompt(retryPromptSource, sourceNode.metadata.portraitTexture)
: retryPromptSource;
const retryContextPrompt = retryMode === "image" && sourceNode.metadata?.portraitTexture ? buildPortraitTexturePrompt(retryPromptSource, sourceNode.metadata.portraitTexture) : retryPromptSource;
if (unchangedModeratedPrompt(node.metadata, retryPromptSource)) {
message.warning("该提示词未通过内容审核,请先修改提示词再重新生成");
return;
@@ -137,7 +153,15 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
return;
}
const useReferenceImages = isEmotionRetry ? false : context?.characterReferences.length ? true : generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
const retryReferenceImages = isEmotionRetry ? [] : hasSavedImageMetadata && savedImageMetadata && !context?.characterReferences.length ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : [];
const retryReferenceImages = isEmotionRetry
? []
: hasSavedImageMetadata && savedImageMetadata && !context?.characterReferences.length
? await resolveMetadataReferences(savedImageMetadata)
: useReferenceImages
? context?.referenceImages.length
? context.referenceImages
: sourceNodeReferenceImages(batchRoot || sourceNode)
: [];
if (useReferenceImages && !retryReferenceImages) {
markMissingReferences(node.id, setNodes);
message.error("参考图片已丢失,无法继续重试");
@@ -151,34 +175,108 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
return;
}
const videoReferenceImages = context?.referenceImages.length ? context.referenceImages : storedVideoImages;
const videoContext = node.type === CanvasNodeType.Video ? { prompt, referenceImages: videoReferenceImages, referenceVideos: context?.referenceVideos || [], referenceAudios: context?.referenceAudios || [], textCount: context?.textCount || 0, imageCount: videoReferenceImages.length, videoCount: context?.referenceVideos.length || 0, audioCount: context?.referenceAudios.length || 0 } : undefined;
const videoContext =
node.type === CanvasNodeType.Video
? {
prompt,
referenceImages: videoReferenceImages,
referenceVideos: context?.referenceVideos || [],
referenceAudios: context?.referenceAudios || [],
textCount: context?.textCount || 0,
imageCount: videoReferenceImages.length,
videoCount: context?.referenceVideos.length || 0,
audioCount: context?.referenceAudios.length || 0,
}
: 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)));
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]) => {
await runCanvasGenerationTaskToConsumer(
{ ...input, ...retryContext },
{
bindTask: (task) => bindGenerationTask(node.id, task),
consumeTask: (task) => applyGenerationTaskResult(node.id, task),
},
);
};
try {
if (node.type === CanvasNodeType.Text) {
if (!context) return;
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "text", prompt, config: generationConfig, referenceImages: context.referenceImages, referenceVideos: context.referenceVideos, signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context.resolvedCharacterVersions }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.text) throw new Error("后端任务没有返回文本");
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: result.text, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } } : item)));
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "text",
prompt,
config: generationConfig,
referenceImages: context.referenceImages,
referenceVideos: context.referenceVideos,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context.resolvedCharacterVersions },
});
return;
}
if (node.type === CanvasNodeType.Video) {
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext);
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt: mediaPrompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoices: context?.resolvedCharacterVoices || [], promptTemplateOperation: node.metadata?.promptTemplateOperation, promptTemplateVariables: node.metadata?.promptTemplateVariables, ...videoGenerationMetadata, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt: mediaPrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, ...videoGenerationMetadata, ...styleMetadata, references: videoContext ? generationReferenceUrls(videoContext) : item.metadata?.references } } : item)));
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: {
...item.metadata,
prompt: mediaPrompt,
model: generationConfig.model,
size: generationConfig.size,
seconds: generationConfig.videoSeconds,
vquality: generationConfig.vquality,
generateAudio: generationConfig.videoGenerateAudio,
watermark: generationConfig.videoWatermark,
...videoGenerationMetadata,
...styleMetadata,
references: videoContext ? generationReferenceUrls(videoContext) : item.metadata?.references,
},
}
: item,
),
);
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "video",
prompt: mediaPrompt,
config: generationConfig,
referenceImages: videoContext?.referenceImages || [],
referenceVideos: videoContext?.referenceVideos || [],
referenceAudios: videoContext?.referenceAudios || [],
signal: controller.signal,
metadata: {
retry: true,
sourceNodeId: sourceNode.id,
resolvedCharacterVersions: context?.resolvedCharacterVersions || [],
resolvedCharacterVoices: context?.resolvedCharacterVoices || [],
promptTemplateOperation: node.metadata?.promptTemplateOperation,
promptTemplateVariables: node.metadata?.promptTemplateVariables,
...videoGenerationMetadata,
...styleMetadata,
},
});
return;
}
if (node.type === CanvasNodeType.Audio) {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "audio", prompt, config: generationConfig, signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoiceKey: context?.resolvedCharacterVoices[0]?.voiceKey }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.audio?.dataUrl) throw new Error("后端任务没有返回音频");
const audio = await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), generationConfig.audioFormat);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...audioMetadata(audio), prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item)));
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item)));
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "audio",
prompt,
config: generationConfig,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoiceKey: context?.resolvedCharacterVoices[0]?.voiceKey },
});
return;
}
@@ -195,30 +293,69 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
const editReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-edit-region`, name: "emotion-edit-region.png", type: "image/png", dataUrl: artifacts.sourceDataUrl };
const characterReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-character`, name: `${emotionEdit.characterName}-face.jpg`, type: "image/jpeg", dataUrl: artifacts.characterDataUrl };
const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size };
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt: mediaPrompt, config: emotionConfig, referenceImages: [editReference, characterReference], mask: { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl }, signal: controller.signal, metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const composited = await compositeEmotionImage(sourceDataUrl, image.dataUrl, artifacts.editRegion, emotionEdit.faceBox);
const uploadedImage = await uploadImage(composited);
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, node.width, node.height);
const generationMetadata = { ...buildImageGenerationMetadata("edit", emotionConfig, 1, [sourceReference]), size: `${artifacts.imageWidth}x${artifacts.imageHeight}` };
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Image, width: imageSize.width, height: imageSize.height, metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt: mediaPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit } } : item)));
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit },
}
: item,
),
);
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "image",
prompt: mediaPrompt,
config: emotionConfig,
referenceImages: [editReference, characterReference],
mask: { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl },
signal: controller.signal,
metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata },
});
return;
}
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt: mediaPrompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], promptTemplateOperation: node.metadata?.promptTemplateOperation, promptTemplateVariables: node.metadata?.promptTemplateVariables, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploadedImage = await uploadImage(image.dataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const requestedImageSize = nodeSizeFromRatio(generationConfig.size || "auto", imageConfig.width, imageConfig.height);
const imageSize = useReferenceImages && !requestedImageSize
? { width: node.width, height: node.height }
: fitNodeSize(uploadedImage.width, uploadedImage.height, requestedImageSize?.width || imageConfig.width, requestedImageSize?.height || imageConfig.height);
const generationMetadata = savedImageMetadata?.generationType
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, transparentBackground: generationConfig.transparentBackground, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
? {
generationType: savedImageMetadata.generationType,
model: generationConfig.model,
size: generationConfig.size,
quality: generationConfig.quality,
transparentBackground: generationConfig.transparentBackground,
count: savedImageMetadata.count || 1,
references: savedImageMetadata.references,
}
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Image, position: { x: item.position.x + item.width / 2 - imageSize.width / 2, y: item.position.y + item.height / 2 - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height, metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt: mediaPrompt, ...generationMetadata, ...styleMetadata } } : item)));
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata },
}
: item,
),
);
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "image",
prompt: mediaPrompt,
config: generationConfig,
referenceImages: useReferenceImages ? retryImages : [],
signal: controller.signal,
metadata: {
retry: true,
sourceNodeId: sourceNode.id,
resolvedCharacterVersions: context?.resolvedCharacterVersions || [],
promptTemplateOperation: node.metadata?.promptTemplateOperation,
promptTemplateVariables: node.metadata?.promptTemplateVariables,
...styleMetadata,
},
});
} catch (error) {
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, retryPromptSource);
@@ -229,7 +366,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
setRunningNodeId(null);
}
},
[addedSkills, assets, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
[addedSkills, applyGenerationTaskResult, assets, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
);
}
+508 -169
View File
@@ -3,12 +3,17 @@ import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { cancelGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, waitForGenerationTask, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskNode, ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { cancelGenerationTask, 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 { generationFailureMetadata } from "@/lib/generation-error";
import { localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { consumeCanvasAgentGenerationContinuation } from "./use-canvas-agent-operations";
type CanvasGenerationRequest = {
targetNodeId: string;
@@ -31,12 +36,172 @@ 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 consumeCanvasAgentGenerationContinuation;
}) {
const isCurrentProject = () => !input.signal.aborted && (input.isCurrentProject?.() ?? true);
if (!isCurrentProject()) return;
const consumeContinuation = input.consumeContinuation ?? consumeCanvasAgentGenerationContinuation;
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 === "agent_storyboard_rows") {
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,
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, input.node.metadata?.composerContent || input.node.metadata?.prompt || "");
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, modal } = 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[]>([]);
@@ -54,179 +219,349 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
if (request?.controller === controller) generationRequestsRef.current.delete(targetNodeId);
}, []);
const stopGenerationByRunningId = useCallback((runningId: string) => {
const affectedNodeIds = new Set<string>();
generationRequestsRef.current.forEach((request) => {
if (request.runningNodeId !== runningId) return;
request.controller.abort();
generationRequestsRef.current.delete(request.targetNodeId);
affectedNodeIds.add(request.targetNodeId);
affectedNodeIds.add(request.originNodeId);
});
setRunningNodeId((current) => (current === runningId ? null : current));
if (!affectedNodeIds.size) return;
setNodes((current) => current.map((node) => affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node));
}, [setNodes]);
const stopGenerationByRunningId = useCallback(
(runningId: string) => {
const affectedNodeIds = new Set<string>();
generationRequestsRef.current.forEach((request) => {
if (request.runningNodeId !== runningId) return;
request.controller.abort();
generationRequestsRef.current.delete(request.targetNodeId);
affectedNodeIds.add(request.targetNodeId);
affectedNodeIds.add(request.originNodeId);
});
setRunningNodeId((current) => (current === runningId ? null : current));
if (!affectedNodeIds.size) return;
setNodes((current) => current.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
},
[setNodes],
);
const confirmStopGeneration = useCallback((nodeId: string) => {
modal.confirm({
title: "停止生成?",
content: "当前生成请求会被中断,已经生成完成的内容会保留。",
okText: "停止",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: () => stopGenerationByRunningId(nodeId),
});
}, [modal, stopGenerationByRunningId]);
const confirmStopGeneration = useCallback(
(nodeId: string) => {
modal.confirm({
title: "停止生成?",
content: "当前生成请求会被中断,已经生成完成的内容会保留。",
okText: "停止",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: () => stopGenerationByRunningId(nodeId),
});
},
[modal, stopGenerationByRunningId],
);
const cancelNodeTask = useCallback((node: CanvasNodeData) => {
const taskId = node.metadata?.taskId;
if (!taskId) {
confirmStopGeneration(node.id);
return;
}
modal.confirm({
title: "取消后台任务?",
content: "任务会在后端停止,已生成完成的内容仍会保留。",
okText: "取消任务",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
generationRequestsRef.current.get(node.id)?.controller.abort();
const task = await cancelGenerationTask(taskId);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, ...generationTaskMetadata(task), status: NODE_STATUS_ERROR, errorDetails: "任务已取消" } } : item));
message.success("任务已取消");
},
});
}, [confirmStopGeneration, message, modal, setNodes]);
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: node.metadata?.prompt || "",
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);
const failure = failed
? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || 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, failedPromptFingerprint: undefined }),
},
};
}));
}, [setNodes]);
const saveGeneratedAsset = useCallback(async (node: CanvasNodeData, taskId: string) => {
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId });
setNodes((current) => current.map((item) => item.id === node.id ? { ...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 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));
}, [nodesRef, setNodes]);
const recoverInterruptedGenerationTasks = useCallback(async () => {
const recoveryNodes = nodesRef.current.filter((node) => {
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length && !node.metadata.taskId;
if (aggregateBatchRoot) return false;
return node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
});
const taskIds = Array.from(new Set(recoveryNodes.map((node) => node.metadata?.taskId).filter((id): id is string => Boolean(id))));
const tasks = (await Promise.all(taskIds.map((id) => queryGenerationTask(id).catch(() => undefined)))).filter((task): task is GenerationTask => Boolean(task));
if (recoveryNodes.some((node) => !node.metadata?.taskId)) {
const recentTasks = await listGenerationTasks(30, { projectId }).catch(() => []);
tasks.push(...recentTasks.filter((task) => !tasks.some((item) => item.id === task.id)));
}
const projectTasks = tasks.filter((task) => task.projectId === projectId && (task.type.startsWith("canvas_") || task.type === "agent_storyboard_rows"));
await Promise.all(recoveryNodes.map(async (node) => {
let task = projectTasks.find((item) => item.id === node.metadata?.taskId) || projectTasks.find((item) => generationTaskNodeId(item) === node.id);
if (!task && node.metadata?.taskId) task = await queryGenerationTask(node.metadata.taskId).catch(() => undefined);
if (!task) {
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "页面刷新后找不到对应任务,请重新生成。" } } : item));
const cancelNodeTask = useCallback(
(node: CanvasNodeData) => {
const taskId = node.metadata?.taskId;
if (!taskId) {
confirmStopGeneration(node.id);
return;
}
if (recoveringTaskIdsRef.current.has(task.id)) return;
recoveringTaskIdsRef.current.add(task.id);
bindGenerationTask(node.id, task);
try {
const completed = task.status === "succeeded" ? task : await waitForGenerationTask(task.id, { initialTask: task });
if (node.type === CanvasNodeType.Script && completed.type === "agent_storyboard_rows") {
const result = storyboardRowsFromTask(completed);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, title: result.title || item.title, metadata: { ...item.metadata, ...generationTaskMetadata(completed), status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined, storyboard: { rows: result.rows, visibleColumns: cinematicStoryboardColumns(item.metadata?.storyboard?.visibleColumns), referenceNodeIds: item.metadata?.storyboard?.referenceNodeIds || [] } } } : item));
} else {
await applyGenerationTaskResult(node.id, completed);
}
} catch (error) {
const failure = generationFailureMetadata(error, node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "");
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item));
} finally {
recoveringTaskIdsRef.current.delete(task.id);
}
}));
setNodes((current) => 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 || "全部图片生成失败",
const cancellationCopy = localDreaminaCancellationCopy({
id: taskId,
status: node.metadata?.taskStatus === "queued" ? "queued" : "running",
stage: node.metadata?.taskStage,
receiptRecorded: node.metadata?.taskReceiptRecorded,
});
modal.confirm({
title: cancellationCopy?.kind === "background" ? "转入后台?" : "取消任务?",
content: cancellationCopy?.confirmation || "任务会在后端停止,已生成完成的内容仍会保留。",
okText: cancellationCopy?.action || "取消任务",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
const task = await cancelGenerationTask(taskId);
const outcome = localDreaminaDetachOutcome(task);
const stopMessage = outcome?.message ?? localDreaminaCancellationMessage(task);
if (outcome?.kind !== "background") generationRequestsRef.current.get(node.id)?.controller.abort();
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: {
...item.metadata,
...generationTaskMetadata(task),
status: outcome?.canvasNodeStatus ?? NODE_STATUS_ERROR,
errorDetails: outcome?.kind === "background" ? undefined : stopMessage,
},
}
: item,
),
);
if (outcome?.kind === "background") message.info(stopMessage);
else message.success(stopMessage);
},
};
}));
}, [applyGenerationTaskResult, bindGenerationTask, nodesRef, projectId, setNodes]);
});
},
[confirmStopGeneration, message, modal, setNodes],
);
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: node.metadata?.prompt || "",
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);
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || 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, failedPromptFingerprint: undefined }),
},
};
}),
);
},
[setNodes],
);
const saveGeneratedAsset = useCallback(
async (node: CanvasNodeData, taskId: string, signal?: AbortSignal) => {
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal });
setNodes((current) => current.map((item) => (item.id === node.id ? { ...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) => {
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 },
);
},
[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 && !node.metadata.taskId;
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(30, { projectId: startedProjectId }, undefined, signal).catch((error) => {
if (!isCurrentProject()) throw error;
return [];
})
).filter((task) => task.projectId === startedProjectId && (task.type.startsWith("canvas_") || task.type === "agent_storyboard_rows"))
: [];
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 ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "页面刷新后找不到对应任务,请重新生成。" } } : 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) => {
if (isCurrentProject() && !continuationOnly) 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, node.metadata?.composerContent || node.metadata?.prompt || "");
setNodes((current) =>
isCurrentProject()
? current.map((item) =>
item.id === node.id
? {
...item,
metadata: {
...item.metadata,
status: continuationOnly ? item.metadata?.status : NODE_STATUS_ERROR,
...(continuationOnly ? {} : failure),
...(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) => {
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 || "全部图片生成失败",
},
};
})
: current,
);
},
[applyGenerationTaskResult, bindGenerationTask, nodesRef, observeSubscribedGenerationTask, setNodes],
);
useEffect(() => {
if (!projectLoaded) return;
void recoverInterruptedGenerationTasks();
}, [projectLoaded, recoverInterruptedGenerationTasks]);
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")) throw error;
});
return () => {
void coordinator.abortAndDrain();
};
}, [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;
@@ -236,14 +571,18 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const saveKey = `${taskId}:${node.id}:${domainProjectId || "personal"}`;
if (autoSavedTaskIdsRef.current.has(saveKey)) return;
autoSavedTaskIdsRef.current.add(saveKey);
void saveGeneratedAsset(node, taskId).catch((error) => {
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;
message.warning(error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败");
});
});
}, [domainProjectId, message, nodes, projectLoaded, saveGeneratedAsset]);
return {
applyGenerationTaskResult,
bindGenerationTask,
cancelNodeTask,
confirmStopGeneration,