feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge

This commit is contained in:
yepwait
2026-08-26 15:02:24 +08:00
committed by ddCat
parent 6141bd688c
commit 86fc3fda2f
82 changed files with 11405 additions and 248 deletions

View File

@@ -37,7 +37,7 @@ export async function executeVideoGeneration({
const videoId = isEmptyVideoNode ? nodeId : nanoid();
const versionRootId = isExistingVideoNode && sourceNode ? sourceNode.metadata?.versionOfNodeId || sourceNode.id : undefined;
const parent = sourceNode?.position || { x: 0, y: 0 };
const videoGenerationMetadata = buildVideoGenerationMetadata(sourceNode, generationContext);
const videoGenerationMetadata = buildVideoGenerationMetadata(sourceNode, generationContext, generationConfig);
const videoNode: CanvasNodeData = {
id: videoId,
type: CanvasNodeType.Video,

View File

@@ -1,4 +1,5 @@
import { Button, Image, Modal } from "antd";
import { XCircle } from "lucide-react";
import { TaskDetailItem } from "./canvas-project-feedback";
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
@@ -13,6 +14,7 @@ type CanvasProjectStatusDialogsProps = {
taskLogs: TaskLog[];
taskLoading: boolean;
onCloseTask: () => void;
onCancelTask?: (task: GenerationTask) => void;
superResolveNode: CanvasNodeData | null;
onCloseSuperResolve: () => void;
previewNode: CanvasNodeData | null;
@@ -22,7 +24,7 @@ type CanvasProjectStatusDialogsProps = {
onConfirmClear: () => void;
};
export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading, superResolveNode, previewNode, clearConfirmOpen, onCloseTask, onCloseSuperResolve, onClosePreview, onCancelClear, onConfirmClear }: CanvasProjectStatusDialogsProps) {
export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading, superResolveNode, previewNode, clearConfirmOpen, onCloseTask, onCancelTask, onCloseSuperResolve, onClosePreview, onCancelClear, onConfirmClear }: CanvasProjectStatusDialogsProps) {
const config = useEffectiveConfig();
return (
<>
@@ -48,6 +50,13 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
</div>
</div>
<TaskGenerationParameters inputJson={task.inputJson} theme={theme} />
{onCancelTask && (task.status === "queued" || task.status === "running") ? (
<div className="flex justify-end">
<Button danger icon={<XCircle className="size-4" />} onClick={() => onCancelTask(task)}>
取消任务
</Button>
</div>
) : null}
<div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
任务日志

View File

@@ -1,6 +1,6 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadMediaFile } from "@/services/file-storage";
@@ -76,6 +76,7 @@ import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
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";
import { cancelGenerationTask } from "@/services/api/task-center";
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
@@ -162,6 +163,7 @@ export default function CanvasPage() {
function InfiniteCanvasPage() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const params = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const projectId = params.id || "";
@@ -434,6 +436,35 @@ function InfiniteCanvasPage() {
taskDetailLogs,
} = useCanvasGeneration({ projectId, domainProjectId: linkedProjectId, projectLoaded, nodes, nodesRef, setNodes });
const cancelCanvasTask = useCallback(
(task: import("@/services/api/task-center").GenerationTask) => {
if (task.provider === "dreamina-cli") {
message.warning("官方即梦 CLI 当前不支持可靠取消,请等待官方状态同步");
return;
}
Modal.confirm({
title: "取消生成任务?",
content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
okText: "取消任务",
okButtonProps: { danger: true },
cancelText: "继续等待",
onOk: async () => {
try {
const next = await cancelGenerationTask(task.id);
const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id);
if (node) bindGenerationTask(node.id, next);
setTaskDetail((current) => (current?.id === task.id ? next : current));
await queryClient.invalidateQueries({ queryKey: ["canvas-active-tasks", projectId] });
message.success("任务已取消");
} catch (error) {
message.error(error instanceof Error ? error.message : "取消任务失败");
}
},
});
},
[bindGenerationTask, message, nodesRef, projectId, queryClient, setTaskDetail],
);
useEffect(() => {
if (!projectLoaded || !codexAutoConnect) return;
if (codexCompactAgent) {
@@ -918,9 +949,14 @@ function InfiniteCanvasPage() {
} else if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Frame) {
setDialogNodeId((current) => (current === node.id ? current : null));
} else {
setDialogNodeId(node.id);
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
setDialogNodeId((current) => {
const currentNode = current ? nodesRef.current.find((item) => item.id === current) : undefined;
return currentNode?.type === CanvasNodeType.Config ? current : node.id;
});
}
}, []);
}, [nodesRef]);
const handleCanvasDeselect = useCallback(() => {
setContextMenu(null);
@@ -2033,7 +2069,7 @@ function InfiniteCanvasPage() {
</CanvasNodeActionContext.Provider>
</InfiniteCanvas>
<CanvasActiveTaskPanel tasks={activeTasks} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />
<CanvasActiveTaskPanel tasks={activeTasks} onCancelTask={cancelCanvasTask} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />
{focusMode ? (
<CanvasFocusModeBar
@@ -2074,6 +2110,7 @@ function InfiniteCanvasPage() {
onAddFolder={createFolder}
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onAddExtensionNode={(type) => createNode(type)}
onAddWorkflow={() => createNode(CanvasNodeType.Config)}
onOpenDirector={() => createDirectorShot()}
onUndo={undoCanvas}
onRedo={redoCanvas}
@@ -2526,6 +2563,7 @@ function InfiniteCanvasPage() {
taskLogs={taskDetailLogs}
taskLoading={taskDetailLoading}
onCloseTask={() => setTaskDetail(null)}
onCancelTask={cancelCanvasTask}
superResolveNode={superResolveNode}
onCloseSuperResolve={() => setSuperResolveNodeId(null)}
previewNode={previewNode}

View File

@@ -13,12 +13,16 @@ export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
});
useEffect(() => {
const handleTaskCreated = (event: Event) => {
const handleTaskChanged = (event: Event) => {
const task = (event as CustomEvent<{ task?: GenerationTask }>).detail?.task;
if (task?.projectId === projectId) void query.refetch();
};
window.addEventListener("canvas:task-created", handleTaskCreated);
return () => window.removeEventListener("canvas:task-created", handleTaskCreated);
window.addEventListener("canvas:task-created", handleTaskChanged);
window.addEventListener("canvas:task-cancelled", handleTaskChanged);
return () => {
window.removeEventListener("canvas:task-created", handleTaskChanged);
window.removeEventListener("canvas:task-cancelled", handleTaskChanged);
};
}, [projectId, query.refetch]);
return {

View File

@@ -10,9 +10,9 @@ import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor,
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import type { AiConfig } from "@/stores/use-config-store";
import { normalizeRunningHubCapability, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
type UseCanvasConnectionControllerOptions = {
projectId: string;
@@ -45,6 +45,20 @@ const CONNECTION_HANDLE_HIT_RADIUS = 40;
const CONNECTION_NODE_HIT_PADDING = 32;
const NODE_STATUS_IDLE = "idle" as const;
function selectRunningHubWorkflow(config: AiConfig) {
const capability = normalizeRunningHubCapability(config.runningHub.capability);
return config.runningHub.workflows.find((item) => item.workflowId.trim() === config.runningHub.workflowId.trim()
&& (item.kind === "app" ? "app" : "workflow") === config.runningHub.selectedKind)
|| config.runningHub.workflows.find((item) => normalizeRunningHubCapability(item.capability, capability) === capability)
|| config.runningHub.workflows[0];
}
function selectComfyBridgeWorkflow(config: AiConfig) {
return config.comfyBridge.workflows.find((item) => item.workflowId.trim() === config.comfyBridge.workflowId.trim())
|| config.comfyBridge.workflows.find((item) => item.capability === config.comfyBridge.capability)
|| config.comfyBridge.workflows[0];
}
export function useCanvasConnectionController({
projectId,
config,
@@ -174,8 +188,9 @@ export function useCanvasConnectionController({
setContextMenu(null);
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
@@ -183,7 +198,7 @@ export function useCanvasConnectionController({
const batchSourceNodes = batchSourceNodeIds
.map((nodeId) => nodesRef.current.find((node) => node.id === nodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const storyboardRow = batchSourceNodeIds.length ? undefined : type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
const storyboardRow = batchSourceNodeIds.length ? undefined : nodeType === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
const batchScriptPrompt = batchSourceNodes
@@ -191,18 +206,39 @@ export function useCanvasConnectionController({
.map((node) => (node.metadata?.content || node.metadata?.prompt || "").trim())
.filter(Boolean)
.join("\n\n");
const scriptPrompt = type === CanvasNodeType.Script
const scriptPrompt = nodeType === CanvasNodeType.Script
? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : ""
: "";
const metadata = type === CanvasNodeType.Drawing
const selectedWorkflowProvider = nodeType === CanvasNodeType.Config
? workflowProvider || (config.runningHub.enabled && config.runningHub.workflows.length ? "runninghub" : config.comfyBridge.enabled && config.comfyBridge.workflows.length ? "comfyui" : "runninghub")
: undefined;
const runningHubWorkflow = selectedWorkflowProvider === "runninghub" ? selectRunningHubWorkflow(config) : undefined;
const comfyBridgeWorkflow = selectedWorkflowProvider === "comfyui" ? selectComfyBridgeWorkflow(config) : undefined;
const workflowCapability = selectedWorkflowProvider === "runninghub"
? normalizeRunningHubCapability(runningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability))
: comfyBridgeWorkflow?.capability || "image";
const metadata: CanvasNodeMetadata | undefined = nodeType === CanvasNodeType.Config
? {
generationMode: selectedWorkflowProvider ? workflowCapability === "video" ? "video" as const : workflowCapability === "audio" ? "audio" as const : "image" as const : "image" as const,
workflowProvider: selectedWorkflowProvider || "model",
status: NODE_STATUS_IDLE,
...(selectedWorkflowProvider === "runninghub" ? {
workflowTitle: "RunningHub 工作流",
...(runningHubWorkflow ? { runningHubWorkflowId: runningHubWorkflow.workflowId, runningHubWorkflowKind: runningHubWorkflow.kind === "app" ? "app" as const : "workflow" as const } : {}),
} : selectedWorkflowProvider === "comfyui" ? {
workflowTitle: "ComfyUI Bridge",
...(comfyBridgeWorkflow ? { comfyBridgeWorkflowId: comfyBridgeWorkflow.workflowId } : {}),
} : {})
}
: nodeType === CanvasNodeType.Drawing
? { drawingEngine: defaultDrawingEngine }
: type === CanvasNodeType.Script && scriptPrompt
: nodeType === CanvasNodeType.Script && scriptPrompt
? { prompt: scriptPrompt, composerContent: scriptPrompt }
: type === CanvasNodeType.Video && storyboardRow
: nodeType === CanvasNodeType.Video && storyboardRow
? { prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(storyboardRow, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
: undefined;
const sourceNodeForQuickCreate = pending.quick ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
const spec = getNodeSpec(type);
const spec = getNodeSpec(nodeType);
const anchorY = sourceNodeForQuickCreate ? sourceNodeForQuickCreate.position.y + sourceNodeForQuickCreate.height * (pending.connection.anchorRatio ?? 0.5) : pending.position.y;
const position = sourceNodeForQuickCreate
? {
@@ -212,9 +248,10 @@ export function useCanvasConnectionController({
y: anchorY,
}
: pending.position;
const newNode = createCanvasNode(type, position, metadata);
const newNode = createCanvasNode(nodeType, position, metadata);
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : "ComfyUI Bridge";
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;
if (batchSourceNodeIds.length && type === CanvasNodeType.Drawing) {
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.Drawing) {
message.error("批量连接暂不支持创建绘图,请先连接到普通节点");
closeConnectionCreateMenu();
return;
@@ -238,7 +275,7 @@ export function useCanvasConnectionController({
setConnections(nextConnections);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
const skippedCount = batchPlan.skipped.length;
const duplicateCount = batchPlan.duplicates.length;
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
@@ -250,7 +287,7 @@ export function useCanvasConnectionController({
}
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
if (!connection) {
message.warning("配置节点之间不能连接");
message.warning("当前节点不能建立这条连线");
return;
}
const policyError = canvasConnectionError(config, [...nodesRef.current, newNode], connectionsRef.current, connection);
@@ -258,7 +295,7 @@ export function useCanvasConnectionController({
message.warning(policyError);
return;
}
if (type === CanvasNodeType.Drawing) {
if (nodeType === CanvasNodeType.Drawing) {
const drawingSourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId);
const sourceUrl = drawingSourceNode?.type === CanvasNodeType.Image ? drawingSourceNode.metadata?.content : "";
if (pending.connection.handleType !== "source" || !drawingSourceNode || !sourceUrl || !newNode.metadata?.drawingId) {
@@ -297,21 +334,22 @@ export function useCanvasConnectionController({
setConnections((currentConnections) => [...currentConnections, { id: nanoid(), ...connected }]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
if (nodeType === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => {
const nodeType = type;
if (pending.batchSourceNodeIds?.length) {
if (type === CanvasNodeType.Drawing) return "批量连接暂不支持绘图";
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: getNodeSpec(type).width, height: getNodeSpec(type).height };
if (nodeType === CanvasNodeType.Drawing || nodeType === CanvasNodeType.Config) return "批量连接暂不支持此节点类型";
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: getNodeSpec(nodeType).width, height: getNodeSpec(nodeType).height };
const plan = planBatchConnections({ sourceNodeIds: pending.batchSourceNodeIds, targetNodeId: pendingNode.id, nodes: [...nodesRef.current, pendingNode], connections: connectionsRef.current, config, allowCapacityOverflow: true });
return plan.connections.length ? "" : plan.skipped[0]?.reason || "当前选中的节点不能连接到此类型";
}
const spec = getNodeSpec(type);
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: spec.width, height: spec.height };
const spec = getNodeSpec(nodeType);
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: spec.width, height: spec.height };
const pendingNodes = [...nodesRef.current, pendingNode];
const connection = normalizeConnection(pending.connection.nodeId, pendingNode.id, pendingNodes, pending.connection.handleType);
if (!connection) return "当前节点类型不能这样连接";

View File

@@ -136,17 +136,18 @@ export function useCanvasGenerationExecutor({
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
try {
const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, assets, promptOnly);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, requirements);
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
const referenceLimits = modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
@@ -337,7 +338,7 @@ function generationModelRequirements(
mode: CanvasNodeGenerationMode,
input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">,
sourceNode: CanvasNodeData | undefined,
config: ReturnType<typeof useEffectiveConfig>,
config: ReturnType<typeof useEffectiveConfig>,
includeCharacterMinimum = false,
): ModelRequirements {
return {
@@ -350,7 +351,7 @@ function generationModelRequirements(
characterCount: includeCharacterMinimum ? input.characterReferences.length : 0,
},
videoOperation: sourceNode?.metadata?.videoEditOperation,
videoSeconds: config.videoSeconds,
options: modelRequestOptions(config, mode),
videoSeconds: config.videoSeconds,
options: config.taskWorkflowProvider === "model" ? modelRequestOptions(config, mode) : undefined,
};
}

View File

@@ -12,6 +12,7 @@ import {
createGenerationRetryContext,
findRetrySourceNode,
generationReferenceUrls,
generationWorkflowMetadata,
isGenerationCanceled,
canvasImageReferenceLimitError,
resolveMetadataReferences,
@@ -21,6 +22,7 @@ import {
sourceNodeReferenceImages,
supportsVideoReferenceAudio,
} from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
@@ -82,17 +84,19 @@ export function useCanvasGenerationRetry({
const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null;
const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined;
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
const generationSourceNode = node.type === CanvasNodeType.Config && isCanvasWorkflowProvider(node.metadata) || node.metadata?.workflowProvider === "model" ? node : sourceNode;
const sourceGenerationConfig = buildGenerationConfig(effectiveConfig, generationSourceNode, retryMode);
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",
...sourceGenerationConfig,
model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model || sourceGenerationConfig.model,
quality: savedImageMetadata.quality || effectiveConfig.quality || sourceGenerationConfig.quality,
size: savedImageMetadata.size || effectiveConfig.size || sourceGenerationConfig.size,
transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground || sourceGenerationConfig.transparentBackground) === "true" ? "true" : "false",
count: "1",
}
: { ...buildGenerationConfig(effectiveConfig, sourceNode, retryMode), count: "1" };
: { ...sourceGenerationConfig, count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
@@ -231,7 +235,7 @@ export function useCanvasGenerationRetry({
return;
}
if (node.type === CanvasNodeType.Video) {
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext);
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext, generationConfig);
setNodes((current) =>
current.map((item) =>
item.id === node.id
@@ -343,6 +347,7 @@ export function useCanvasGenerationRetry({
transparentBackground: generationConfig.transparentBackground,
count: savedImageMetadata.count || 1,
references: savedImageMetadata.references,
...generationWorkflowMetadata(generationConfig),
}
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
setNodes((current) =>

View File

@@ -282,13 +282,17 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const applyGenerationTaskResult = useCallback(
async (nodeId: string, task: GenerationTask) => {
const applyStoredTaskResult = async () => {
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
nodesRef.current = applied.nodes;
setNodes(applied.nodes);
};
if (!task.outputs?.length && task.type === "canvas_text") {
await applyStoredTaskResult();
return;
}
try {
if (!task.outputs?.length && task.type === "canvas_text") {
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
setNodes((current) => current.map((node) => (node.id === applied.nodeId ? applied.node! : node)));
return;
}
await consumeGenerationTaskNode(
task,
nodeId,
@@ -307,11 +311,24 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
},
{ signal: consumerControllerRef.current.signal },
);
} catch (error) {
if (generationTaskCanReloadResource(task)) {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, resourceReloadAvailable: true } } : node)));
const currentNode = nodesRef.current.find((node) => node.id === nodeId || node.metadata?.taskId === task.id);
if (task.status === "succeeded" && (!currentNode?.metadata?.content || currentNode.metadata.status !== NODE_STATUS_SUCCESS)) {
// attach effect 可能已经完成,但旧画布快照仍停留在 loading。
// 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。
await applyStoredTaskResult();
}
} catch (error) {
// 成功任务的副作用确认失败时,直接用已持久化结果回写节点,避免永久停留在生成中。
if (task.status === "succeeded") {
await applyStoredTaskResult().catch(() => {
throw error;
});
} else {
if (generationTaskCanReloadResource(task)) {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, resourceReloadAvailable: true } } : node)));
}
throw error;
}
throw error;
}
},
[nodesRef, projectId, setNodes],

View File

@@ -159,7 +159,12 @@ export function useCanvasNodeEditor({
}, [setNodes]);
const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial<CanvasNodeMetadata>) => {
setNodes((current) => current.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
setNodes((current) => {
const next = current.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node));
// 生成入口读取 nodesRef;同步写入,避免刚修改工作流比例就立即生成时仍提交旧值。
nodesRef.current = next;
return next;
});
if (!patch.assetCategory) return;
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node?.metadata?.content?.trim()) return;

View File

@@ -8,10 +8,11 @@ import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, g
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
type CanvasClipboard = {
nodes: CanvasNodeData[];
@@ -54,6 +55,7 @@ export function useCanvasNodeOperations({
onNodesDeleted,
}: UseCanvasNodeOperationsOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
const clipboardRef = useRef<CanvasClipboard | null>(null);
const preferCopiedNodesRef = useRef(false);
@@ -122,16 +124,26 @@ export function useCanvasNodeOperations({
setSelectedConnectionId(null);
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position) => {
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position, workflowProvider?: "runninghub" | "comfyui") => {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
const node = createCanvasNode(type, position || getCanvasCenter(), type === CanvasNodeType.Drawing ? { drawingEngine: defaultDrawingEngine } : undefined);
const selectedWorkflowProvider = type === CanvasNodeType.Config
? workflowProvider || "model"
: undefined;
const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : type === CanvasNodeType.Config && selectedWorkflowProvider === "comfyui" ? "ComfyUI Bridge" : undefined;
const metadata: CanvasNodeMetadata | undefined = type === CanvasNodeType.Drawing
? { drawingEngine: defaultDrawingEngine }
: type === CanvasNodeType.Config
? { generationMode: "image", workflowProvider: selectedWorkflowProvider || "model" }
: undefined;
const node = createCanvasNode(type, position || getCanvasCenter(), metadata);
if (workflowTitle) node.title = workflowTitle;
commitNodes([...nodesRef.current, node]);
selectNodes(new Set([node.id]));
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.comfyBridge.enabled, effectiveConfig.comfyBridge.workflows.length, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), {

View File

@@ -151,7 +151,7 @@ export function useCanvasStoryboard({
characters: storyboardContext.characters,
shotDurationSeconds,
shotCount: requestedShotCount,
config: backendProviderConfig(generationConfig),
config: backendProviderConfig(generationConfig, "text"),
metadata: { nodeId },
},
});