feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user