diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index cc1e17cf51..9a45dd89a5 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -75,6 +75,7 @@
"@reduxjs/toolkit": "^1.9.5",
"@roarr/browser-log-writer": "^1.1.5",
"@stevebel/png": "^1.5.1",
+ "compare-versions": "^6.1.0",
"dateformat": "^5.0.3",
"formik": "^2.4.3",
"framer-motion": "^10.16.1",
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
index 4afe023fbb..261edba0af 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
@@ -84,6 +84,7 @@ import { addUserInvokedCanvasListener } from './listeners/userInvokedCanvas';
import { addUserInvokedImageToImageListener } from './listeners/userInvokedImageToImage';
import { addUserInvokedNodesListener } from './listeners/userInvokedNodes';
import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextToImage';
+import { addWorkflowLoadedListener } from './listeners/workflowLoaded';
export const listenerMiddleware = createListenerMiddleware();
@@ -202,6 +203,9 @@ addBoardIdSelectedListener();
// Node schemas
addReceivedOpenAPISchemaListener();
+// Workflows
+addWorkflowLoadedListener();
+
// DND
addImageDroppedListener();
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoaded.ts
new file mode 100644
index 0000000000..c447720941
--- /dev/null
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoaded.ts
@@ -0,0 +1,55 @@
+import { logger } from 'app/logging/logger';
+import { workflowLoadRequested } from 'features/nodes/store/actions';
+import { workflowLoaded } from 'features/nodes/store/nodesSlice';
+import { $flow } from 'features/nodes/store/reactFlowInstance';
+import { validateWorkflow } from 'features/nodes/util/validateWorkflow';
+import { addToast } from 'features/system/store/systemSlice';
+import { makeToast } from 'features/system/util/makeToast';
+import { setActiveTab } from 'features/ui/store/uiSlice';
+import { startAppListening } from '..';
+
+export const addWorkflowLoadedListener = () => {
+ startAppListening({
+ actionCreator: workflowLoadRequested,
+ effect: (action, { dispatch, getState }) => {
+ const log = logger('nodes');
+ const workflow = action.payload;
+ const nodeTemplates = getState().nodes.nodeTemplates;
+
+ const { workflow: validatedWorkflow, errors } = validateWorkflow(
+ workflow,
+ nodeTemplates
+ );
+
+ dispatch(workflowLoaded(validatedWorkflow));
+
+ if (!errors.length) {
+ dispatch(
+ addToast(
+ makeToast({
+ title: 'Workflow Loaded',
+ status: 'success',
+ })
+ )
+ );
+ } else {
+ dispatch(
+ addToast(
+ makeToast({
+ title: 'Workflow Loaded with Warnings',
+ status: 'warning',
+ })
+ )
+ );
+ errors.forEach(({ message, ...rest }) => {
+ log.warn(rest, message);
+ });
+ }
+
+ dispatch(setActiveTab('nodes'));
+ requestAnimationFrame(() => {
+ $flow.get()?.fitView();
+ });
+ },
+ });
+};
diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
index 3559679fc4..846cf5a6f0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx
@@ -17,16 +17,13 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
-import { workflowLoaded } from 'features/nodes/store/nodesSlice';
+import { workflowLoadRequested } from 'features/nodes/store/actions';
import ParamUpscalePopover from 'features/parameters/components/Parameters/Upscale/ParamUpscaleSettings';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
-import { addToast } from 'features/system/store/systemSlice';
-import { makeToast } from 'features/system/util/makeToast';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import {
- setActiveTab,
setShouldShowImageDetails,
setShouldShowProgressInViewer,
} from 'features/ui/store/uiSlice';
@@ -124,16 +121,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
if (!workflow) {
return;
}
- dispatch(workflowLoaded(workflow));
- dispatch(setActiveTab('nodes'));
- dispatch(
- addToast(
- makeToast({
- title: 'Workflow Loaded',
- status: 'success',
- })
- )
- );
+ dispatch(workflowLoadRequested(workflow));
}, [dispatch, workflow]);
const handleClickUseAllParameters = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
index e75a7745bb..90272a3a86 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx
@@ -7,12 +7,9 @@ import {
isModalOpenChanged,
} from 'features/changeBoardModal/store/slice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
-import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
-import { addToast } from 'features/system/store/systemSlice';
-import { makeToast } from 'features/system/util/makeToast';
import { useCopyImageToClipboard } from 'features/ui/hooks/useCopyImageToClipboard';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
@@ -36,6 +33,7 @@ import {
} from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
+import { workflowLoadRequested } from 'features/nodes/store/actions';
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
@@ -102,16 +100,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
if (!workflow) {
return;
}
- dispatch(workflowLoaded(workflow));
- dispatch(setActiveTab('nodes'));
- dispatch(
- addToast(
- makeToast({
- title: 'Workflow Loaded',
- status: 'success',
- })
- )
- );
+ dispatch(workflowLoadRequested(workflow));
}, [dispatch, workflow]);
const handleSendToImageToImage = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
index e8fb66d074..16af1fe12c 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
@@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { $flow } from 'features/nodes/store/reactFlowInstance';
import { contextMenusClosed } from 'features/ui/store/uiSlice';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -13,6 +14,7 @@ import {
OnConnectStart,
OnEdgesChange,
OnEdgesDelete,
+ OnInit,
OnMoveEnd,
OnNodesChange,
OnNodesDelete,
@@ -147,6 +149,11 @@ export const Flow = () => {
dispatch(contextMenusClosed());
}, [dispatch]);
+ const onInit: OnInit = useCallback((flow) => {
+ $flow.set(flow);
+ flow.fitView();
+ }, []);
+
useHotkeys(['Ctrl+c', 'Meta+c'], (e) => {
e.preventDefault();
dispatch(selectionCopied());
@@ -170,6 +177,7 @@ export const Flow = () => {
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
+ onInit={onInit}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx
index eae05688b5..143785ecfe 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx
@@ -12,6 +12,7 @@ import {
Tooltip,
useDisclosure,
} from '@chakra-ui/react';
+import { compare } from 'compare-versions';
import { useNodeData } from 'features/nodes/hooks/useNodeData';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
@@ -20,6 +21,7 @@ import { isInvocationNodeData } from 'features/nodes/types/types';
import { memo, useMemo } from 'react';
import { FaInfoCircle } from 'react-icons/fa';
import NotesTextarea from './NotesTextarea';
+import { useDoNodeVersionsMatch } from 'features/nodes/hooks/useDoNodeVersionsMatch';
interface Props {
nodeId: string;
@@ -29,6 +31,7 @@ const InvocationNodeNotes = ({ nodeId }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const label = useNodeLabel(nodeId);
const title = useNodeTemplateTitle(nodeId);
+ const doVersionsMatch = useDoNodeVersionsMatch(nodeId);
return (
<>
@@ -50,7 +53,11 @@ const InvocationNodeNotes = ({ nodeId }: Props) => {
>
@@ -92,16 +99,59 @@ const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
return 'Unknown Node';
}, [data, nodeTemplate]);
+ const versionComponent = useMemo(() => {
+ if (!isInvocationNodeData(data) || !nodeTemplate) {
+ return null;
+ }
+
+ if (!data.version) {
+ return (
+
+ Version unknown
+
+ );
+ }
+
+ if (!nodeTemplate.version) {
+ return (
+
+ Version {data.version} (unknown template)
+
+ );
+ }
+
+ if (compare(data.version, nodeTemplate.version, '<')) {
+ return (
+
+ Version {data.version} (update node)
+
+ );
+ }
+
+ if (compare(data.version, nodeTemplate.version, '>')) {
+ return (
+
+ Version {data.version} (update app)
+
+ );
+ }
+
+ return Version {data.version};
+ }, [data, nodeTemplate]);
+
if (!isInvocationNodeData(data)) {
return Unknown Node;
}
return (
- {title}
+
+ {title}
+
{nodeTemplate?.description}
+ {versionComponent}
{data?.notes && {data.notes}}
);
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts b/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts
index a88a82e1fc..24982f591e 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts
@@ -138,13 +138,14 @@ export const useBuildNodeData = () => {
data: {
id: nodeId,
type,
- inputs,
- outputs,
- isOpen: true,
+ version: template.version,
label: '',
notes: '',
+ isOpen: true,
embedWorkflow: false,
isIntermediate: true,
+ inputs,
+ outputs,
},
};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useDoNodeVersionsMatch.ts b/invokeai/frontend/web/src/features/nodes/hooks/useDoNodeVersionsMatch.ts
new file mode 100644
index 0000000000..926c56ac1e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useDoNodeVersionsMatch.ts
@@ -0,0 +1,33 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { compareVersions } from 'compare-versions';
+import { useMemo } from 'react';
+import { isInvocationNode } from '../types/types';
+
+export const useDoNodeVersionsMatch = (nodeId: string) => {
+ const selector = useMemo(
+ () =>
+ createSelector(
+ stateSelector,
+ ({ nodes }) => {
+ const node = nodes.nodes.find((node) => node.id === nodeId);
+ if (!isInvocationNode(node)) {
+ return false;
+ }
+ const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
+ if (!nodeTemplate?.version || !node.data?.version) {
+ return false;
+ }
+ return compareVersions(nodeTemplate.version, node.data.version) === 0;
+ },
+ defaultSelectorOptions
+ ),
+ [nodeId]
+ );
+
+ const nodeTemplate = useAppSelector(selector);
+
+ return nodeTemplate;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx b/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx
index 97f2cea77b..7f015ac5eb 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx
@@ -2,13 +2,13 @@ import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { useLogger } from 'app/logging/useLogger';
import { useAppDispatch } from 'app/store/storeHooks';
import { parseify } from 'common/util/serialize';
-import { workflowLoaded } from 'features/nodes/store/nodesSlice';
-import { zValidatedWorkflow } from 'features/nodes/types/types';
+import { zWorkflow } from 'features/nodes/types/types';
import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { memo, useCallback } from 'react';
import { ZodError } from 'zod';
import { fromZodError, fromZodIssue } from 'zod-validation-error';
+import { workflowLoadRequested } from '../store/actions';
export const useLoadWorkflowFromFile = () => {
const dispatch = useAppDispatch();
@@ -24,7 +24,7 @@ export const useLoadWorkflowFromFile = () => {
try {
const parsedJSON = JSON.parse(String(rawJSON));
- const result = zValidatedWorkflow.safeParse(parsedJSON);
+ const result = zWorkflow.safeParse(parsedJSON);
if (!result.success) {
const { message } = fromZodError(result.error, {
@@ -45,32 +45,8 @@ export const useLoadWorkflowFromFile = () => {
reader.abort();
return;
}
- dispatch(workflowLoaded(result.data.workflow));
- if (!result.data.warnings.length) {
- dispatch(
- addToast(
- makeToast({
- title: 'Workflow Loaded',
- status: 'success',
- })
- )
- );
- reader.abort();
- return;
- }
-
- dispatch(
- addToast(
- makeToast({
- title: 'Workflow Loaded with Warnings',
- status: 'warning',
- })
- )
- );
- result.data.warnings.forEach(({ message, ...rest }) => {
- logger.warn(rest, message);
- });
+ dispatch(workflowLoadRequested(result.data));
reader.abort();
} catch {
diff --git a/invokeai/frontend/web/src/features/nodes/store/actions.ts b/invokeai/frontend/web/src/features/nodes/store/actions.ts
index 2463a1e945..cf7ccf8238 100644
--- a/invokeai/frontend/web/src/features/nodes/store/actions.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/actions.ts
@@ -1,5 +1,6 @@
import { createAction, isAnyOf } from '@reduxjs/toolkit';
import { Graph } from 'services/api/types';
+import { Workflow } from '../types/types';
export const textToImageGraphBuilt = createAction(
'nodes/textToImageGraphBuilt'
@@ -16,3 +17,7 @@ export const isAnyGraphBuilt = isAnyOf(
canvasGraphBuilt,
nodesGraphBuilt
);
+
+export const workflowLoadRequested = createAction(
+ 'nodes/workflowLoadRequested'
+);
diff --git a/invokeai/frontend/web/src/features/nodes/store/reactFlowInstance.ts b/invokeai/frontend/web/src/features/nodes/store/reactFlowInstance.ts
new file mode 100644
index 0000000000..e9094a9310
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/reactFlowInstance.ts
@@ -0,0 +1,4 @@
+import { atom } from 'nanostores';
+import { ReactFlowInstance } from 'reactflow';
+
+export const $flow = atom(null);
diff --git a/invokeai/frontend/web/src/features/nodes/types/types.ts b/invokeai/frontend/web/src/features/nodes/types/types.ts
index f7986a5028..402ef4ac7a 100644
--- a/invokeai/frontend/web/src/features/nodes/types/types.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/types.ts
@@ -52,6 +52,10 @@ export type InvocationTemplate = {
* The type of this node's output
*/
outputType: string; // TODO: generate a union of output types
+ /**
+ * The invocation's version.
+ */
+ version?: string;
};
export type FieldUIConfig = {
@@ -962,6 +966,7 @@ export type InvocationSchemaExtra = {
title: string;
category?: string;
tags?: string[];
+ version?: string;
properties: Omit<
NonNullable &
(_InputField | _OutputField),
@@ -1095,6 +1100,29 @@ export const zCoreMetadata = z
export type CoreMetadata = z.infer;
+export const zSemVer = z.string().refine((val) => {
+ const [major, minor, patch] = val.split('.');
+ return (
+ major !== undefined &&
+ Number.isInteger(Number(major)) &&
+ minor !== undefined &&
+ Number.isInteger(Number(minor)) &&
+ patch !== undefined &&
+ Number.isInteger(Number(patch))
+ );
+});
+
+export const zParsedSemver = zSemVer.transform((val) => {
+ const [major, minor, patch] = val.split('.');
+ return {
+ major: Number(major),
+ minor: Number(minor),
+ patch: Number(patch),
+ };
+});
+
+export type SemVer = z.infer;
+
export const zInvocationNodeData = z.object({
id: z.string().trim().min(1),
// no easy way to build this dynamically, and we don't want to anyways, because this will be used
@@ -1107,6 +1135,7 @@ export const zInvocationNodeData = z.object({
notes: z.string(),
embedWorkflow: z.boolean(),
isIntermediate: z.boolean(),
+ version: zSemVer.optional(),
});
// Massage this to get better type safety while developing
@@ -1195,20 +1224,6 @@ export const zFieldIdentifier = z.object({
export type FieldIdentifier = z.infer;
-export const zSemVer = z.string().refine((val) => {
- const [major, minor, patch] = val.split('.');
- return (
- major !== undefined &&
- minor !== undefined &&
- patch !== undefined &&
- Number.isInteger(Number(major)) &&
- Number.isInteger(Number(minor)) &&
- Number.isInteger(Number(patch))
- );
-});
-
-export type SemVer = z.infer;
-
export type WorkflowWarning = {
message: string;
issues: string[];
diff --git a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts
index 78e7495481..d8bb189abc 100644
--- a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts
@@ -73,6 +73,7 @@ export const parseSchema = (
const title = schema.title.replace('Invocation', '');
const tags = schema.tags ?? [];
const description = schema.description ?? '';
+ const version = schema.version ?? '';
const inputs = reduce(
schema.properties,
@@ -225,11 +226,12 @@ export const parseSchema = (
const invocation: InvocationTemplate = {
title,
type,
+ version,
tags,
description,
+ outputType,
inputs,
outputs,
- outputType,
};
Object.assign(invocationsAccumulator, { [type]: invocation });
diff --git a/invokeai/frontend/web/src/features/nodes/util/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/validateWorkflow.ts
new file mode 100644
index 0000000000..a3085d516b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/validateWorkflow.ts
@@ -0,0 +1,96 @@
+import { compareVersions } from 'compare-versions';
+import { cloneDeep, keyBy } from 'lodash-es';
+import {
+ InvocationTemplate,
+ Workflow,
+ WorkflowWarning,
+ isWorkflowInvocationNode,
+} from '../types/types';
+import { parseify } from 'common/util/serialize';
+
+export const validateWorkflow = (
+ workflow: Workflow,
+ nodeTemplates: Record
+) => {
+ const clone = cloneDeep(workflow);
+ const { nodes, edges } = clone;
+ const errors: WorkflowWarning[] = [];
+ const invocationNodes = nodes.filter(isWorkflowInvocationNode);
+ const keyedNodes = keyBy(invocationNodes, 'id');
+ nodes.forEach((node) => {
+ if (!isWorkflowInvocationNode(node)) {
+ return;
+ }
+
+ const nodeTemplate = nodeTemplates[node.data.type];
+ if (!nodeTemplate) {
+ errors.push({
+ message: `Node "${node.data.type}" skipped`,
+ issues: [`Node type "${node.data.type}" does not exist`],
+ data: node,
+ });
+ return;
+ }
+
+ if (
+ nodeTemplate.version &&
+ node.data.version &&
+ compareVersions(nodeTemplate.version, node.data.version) !== 0
+ ) {
+ errors.push({
+ message: `Node "${node.data.type}" has mismatched version`,
+ issues: [
+ `Node "${node.data.type}" v${node.data.version} may be incompatible with installed v${nodeTemplate.version}`,
+ ],
+ data: { node, nodeTemplate: parseify(nodeTemplate) },
+ });
+ return;
+ }
+ });
+ edges.forEach((edge, i) => {
+ const sourceNode = keyedNodes[edge.source];
+ const targetNode = keyedNodes[edge.target];
+ const issues: string[] = [];
+ if (!sourceNode) {
+ issues.push(`Output node ${edge.source} does not exist`);
+ } else if (
+ edge.type === 'default' &&
+ !(edge.sourceHandle in sourceNode.data.outputs)
+ ) {
+ issues.push(
+ `Output field "${edge.source}.${edge.sourceHandle}" does not exist`
+ );
+ }
+ if (!targetNode) {
+ issues.push(`Input node ${edge.target} does not exist`);
+ } else if (
+ edge.type === 'default' &&
+ !(edge.targetHandle in targetNode.data.inputs)
+ ) {
+ issues.push(
+ `Input field "${edge.target}.${edge.targetHandle}" does not exist`
+ );
+ }
+ if (!nodeTemplates[sourceNode?.data.type ?? '__UNKNOWN_NODE_TYPE__']) {
+ issues.push(
+ `Source node "${edge.source}" missing template "${sourceNode?.data.type}"`
+ );
+ }
+ if (!nodeTemplates[targetNode?.data.type ?? '__UNKNOWN_NODE_TYPE__']) {
+ issues.push(
+ `Source node "${edge.target}" missing template "${targetNode?.data.type}"`
+ );
+ }
+ if (issues.length) {
+ delete edges[i];
+ const src = edge.type === 'default' ? edge.sourceHandle : edge.source;
+ const tgt = edge.type === 'default' ? edge.targetHandle : edge.target;
+ errors.push({
+ message: `Edge "${src} -> ${tgt}" skipped`,
+ issues,
+ data: edge,
+ });
+ }
+ });
+ return { workflow: clone, errors };
+};
diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts
index f48892113d..6e00d1b38b 100644
--- a/invokeai/frontend/web/src/services/api/schema.d.ts
+++ b/invokeai/frontend/web/src/services/api/schema.d.ts
@@ -6981,6 +6981,11 @@ export type components = {
* @description The node's category
*/
category?: string;
+ /**
+ * Version
+ * @description The node's version. Should be a valid semver string e.g. "1.0.0" or "3.8.13".
+ */
+ version?: string;
};
/**
* Input
@@ -7036,24 +7041,12 @@ export type components = {
/** Ui Order */
ui_order?: number;
};
- /**
- * StableDiffusionOnnxModelFormat
- * @description An enumeration.
- * @enum {string}
- */
- StableDiffusionOnnxModelFormat: "olive" | "onnx";
/**
* StableDiffusion1ModelFormat
* @description An enumeration.
* @enum {string}
*/
StableDiffusion1ModelFormat: "checkpoint" | "diffusers";
- /**
- * ControlNetModelFormat
- * @description An enumeration.
- * @enum {string}
- */
- ControlNetModelFormat: "checkpoint" | "diffusers";
/**
* StableDiffusionXLModelFormat
* @description An enumeration.
@@ -7066,6 +7059,18 @@ export type components = {
* @enum {string}
*/
StableDiffusion2ModelFormat: "checkpoint" | "diffusers";
+ /**
+ * ControlNetModelFormat
+ * @description An enumeration.
+ * @enum {string}
+ */
+ ControlNetModelFormat: "checkpoint" | "diffusers";
+ /**
+ * StableDiffusionOnnxModelFormat
+ * @description An enumeration.
+ * @enum {string}
+ */
+ StableDiffusionOnnxModelFormat: "olive" | "onnx";
};
responses: never;
parameters: never;
diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock
index 2c3c9ae88f..787c81a756 100644
--- a/invokeai/frontend/web/yarn.lock
+++ b/invokeai/frontend/web/yarn.lock
@@ -2970,6 +2970,11 @@ commondir@^1.0.1:
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==
+compare-versions@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.0.tgz#3f2131e3ae93577df111dba133e6db876ffe127a"
+ integrity sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==
+
compute-scroll-into-view@1.0.20:
version "1.0.20"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz#1768b5522d1172754f5d0c9b02de3af6be506a43"