From b58494c42071dfd026d13699bc38b1950d87474a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 9 Apr 2024 10:17:03 +1000
Subject: [PATCH] feat(ui): add graph-to-workflow debug helper
This is intended for debug usage, so it's hidden away in the workflow library `...` menu. Hold shift to see the button for it.
- Paste a graph (from a network request, for example) and then click the convert button to convert it to a workflow.
- Disable auto layout to stack the nodes with an offset (try it out). If you change this, you must re-convert to get the changes.
- Edit the workflow JSON if you need to tweak something before loading it.
---
invokeai/frontend/web/package.json | 1 +
invokeai/frontend/web/pnpm-lock.yaml | 9 ++
invokeai/frontend/web/public/locales/en.json | 7 +-
.../features/nodes/components/NodeEditor.tsx | 2 +
.../nodes/util/workflow/graphToWorkflow.ts | 148 ++++++++++++++++++
.../LoadWorkflowFromGraphModal.tsx | 111 +++++++++++++
.../LoadWorkflowFromGraphMenuItem.tsx | 18 +++
.../WorkflowLibraryMenu.tsx | 5 +
8 files changed, 300 insertions(+), 1 deletion(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts
create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx
create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/LoadWorkflowFromGraphMenuItem.tsx
diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 81fc9c4dd3..a99910c549 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -52,6 +52,7 @@
},
"dependencies": {
"@chakra-ui/react-use-size": "^2.1.0",
+ "@dagrejs/dagre": "^1.1.1",
"@dagrejs/graphlib": "^2.2.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index 4be16619ec..bf423c3d46 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -11,6 +11,9 @@ dependencies:
'@chakra-ui/react-use-size':
specifier: ^2.1.0
version: 2.1.0(react@18.2.0)
+ '@dagrejs/dagre':
+ specifier: ^1.1.1
+ version: 1.1.1
'@dagrejs/graphlib':
specifier: ^2.2.1
version: 2.2.1
@@ -3092,6 +3095,12 @@ packages:
dev: true
optional: true
+ /@dagrejs/dagre@1.1.1:
+ resolution: {integrity: sha512-AQfT6pffEuPE32weFzhS/u3UpX+bRXUARIXL7UqLaxz497cN8pjuBlX6axO4IIECE2gBV8eLFQkGCtKX5sDaUA==}
+ dependencies:
+ '@dagrejs/graphlib': 2.2.1
+ dev: false
+
/@dagrejs/graphlib@2.2.1:
resolution: {integrity: sha512-xJsN1v6OAxXk6jmNdM+OS/bBE8nDCwM0yDNprXR18ZNatL6to9ggod9+l2XtiLhXfLm0NkE7+Er/cpdlM+SkUA==}
engines: {node: '>17.0.0'}
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 9686f8a02a..5454c72e68 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -849,6 +849,7 @@
"version": "Version",
"versionUnknown": " Version Unknown",
"workflow": "Workflow",
+ "graph": "Graph",
"workflowAuthor": "Author",
"workflowContact": "Contact",
"workflowDescription": "Short Description",
@@ -1482,7 +1483,11 @@
"workflowName": "Workflow Name",
"newWorkflowCreated": "New Workflow Created",
"workflowCleared": "Workflow Cleared",
- "workflowEditorMenu": "Workflow Editor Menu"
+ "workflowEditorMenu": "Workflow Editor Menu",
+ "loadFromGraph": "Load Workflow from Graph",
+ "convertGraph": "Convert Graph",
+ "loadWorkflow": "$t(common.load) Workflow",
+ "autoLayout": "Auto Layout"
},
"app": {
"storeNotInitialized": "Store is not initialized"
diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx
index 8307997ff9..737adb52e7 100644
--- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx
@@ -3,6 +3,7 @@ import 'reactflow/dist/style.css';
import { Flex } from '@invoke-ai/ui-library';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
+import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
@@ -61,6 +62,7 @@ const NodeEditor = () => {
+
)}
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts
new file mode 100644
index 0000000000..eec9c6cf4b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts
@@ -0,0 +1,148 @@
+import * as dagre from '@dagrejs/dagre';
+import { logger } from 'app/logging/logger';
+import { getStore } from 'app/store/nanostores/store';
+import { NODE_WIDTH } from 'features/nodes/types/constants';
+import type { FieldInputInstance } from 'features/nodes/types/field';
+import type { WorkflowV3 } from 'features/nodes/types/workflow';
+import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance';
+import { t } from 'i18next';
+import { forEach } from 'lodash-es';
+import type { NonNullableGraph } from 'services/api/types';
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * Converts a graph to a workflow. This is a best-effort conversion and may not be perfect.
+ * For example, if a graph references an unknown node type, that node will be skipped.
+ * @param graph The graph to convert to a workflow
+ * @param autoLayout Whether to auto-layout the nodes using `dagre`. If false, nodes will be simply stacked on top of one another with an offset.
+ * @returns The workflow.
+ */
+export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): WorkflowV3 => {
+ const invocationTemplates = getStore().getState().nodes.templates;
+
+ if (!invocationTemplates) {
+ throw new Error(t('app.storeNotInitialized'));
+ }
+
+ // Initialize the workflow
+ const workflow: WorkflowV3 = {
+ name: '',
+ author: '',
+ contact: '',
+ description: '',
+ meta: {
+ category: 'user',
+ version: '3.0.0',
+ },
+ notes: '',
+ tags: '',
+ version: '',
+ exposedFields: [],
+ edges: [],
+ nodes: [],
+ };
+
+ // Convert nodes
+ forEach(graph.nodes, (node) => {
+ const template = invocationTemplates[node.type];
+
+ // Skip missing node templates - this is a best-effort
+ if (!template) {
+ logger('nodes').warn(`Node type ${node.type} not found in invocationTemplates`);
+ return;
+ }
+
+ // Build field input instances for each attr
+ const inputs: Record = {};
+
+ forEach(node, (value, key) => {
+ // Ignore the non-input keys - I think this is all of them?
+ if (key === 'id' || key === 'type' || key === 'is_intermediate' || key === 'use_cache') {
+ return;
+ }
+
+ const inputTemplate = template.inputs[key];
+
+ // Skip missing input templates
+ if (!inputTemplate) {
+ logger('nodes').warn(`Input ${key} not found in template for node type ${node.type}`);
+ return;
+ }
+
+ // This _should_ be all we need to do!
+ const inputInstance = buildFieldInputInstance(node.id, inputTemplate);
+ inputInstance.value = value;
+ inputs[key] = inputInstance;
+ });
+
+ workflow.nodes.push({
+ id: node.id,
+ type: 'invocation',
+ position: { x: 0, y: 0 }, // we'll do layout later, just need something here
+ data: {
+ id: node.id,
+ type: node.type,
+ version: template.version,
+ label: '',
+ notes: '',
+ isOpen: true,
+ isIntermediate: node.is_intermediate ?? false,
+ useCache: node.use_cache ?? true,
+ inputs,
+ },
+ });
+ });
+
+ forEach(graph.edges, (edge) => {
+ workflow.edges.push({
+ id: uuidv4(), // we don't have edge IDs in the graph
+ type: 'default',
+ source: edge.source.node_id,
+ sourceHandle: edge.source.field,
+ target: edge.destination.node_id,
+ targetHandle: edge.destination.field,
+ });
+ });
+
+ if (autoLayout) {
+ // Best-effort auto layout via dagre - not perfect but better than nothing
+ const dagreGraph = new dagre.graphlib.Graph();
+ // `rankdir` and `align` could be tweaked, but it's gonna be janky no matter what we choose
+ dagreGraph.setGraph({ rankdir: 'TB', align: 'UL' });
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+ // We don't know the dimensions of the nodes until we load the graph into `reactflow` - use a reasonable value
+ forEach(graph.nodes, (node) => {
+ const width = NODE_WIDTH;
+ const height = NODE_WIDTH * 1.5;
+ dagreGraph.setNode(node.id, { width, height });
+ });
+
+ graph.edges.forEach((edge) => {
+ dagreGraph.setEdge(edge.source.node_id, edge.destination.node_id);
+ });
+
+ // This does the magic
+ dagre.layout(dagreGraph);
+
+ // Update the workflow now that we've got the positions
+ workflow.nodes.forEach((node) => {
+ const nodeWithPosition = dagreGraph.node(node.id);
+ node.position = {
+ x: nodeWithPosition.x - nodeWithPosition.width / 2,
+ y: nodeWithPosition.y - nodeWithPosition.height / 2,
+ };
+ });
+ } else {
+ // Stack nodes with a 50px,50px offset from the previous ndoe
+ let x = 0;
+ let y = 0;
+ workflow.nodes.forEach((node) => {
+ node.position = { x, y };
+ x = x + 50;
+ y = y + 50;
+ });
+ }
+
+ return workflow;
+};
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx
new file mode 100644
index 0000000000..ecb4aa7dd4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx
@@ -0,0 +1,111 @@
+import {
+ Button,
+ Checkbox,
+ Flex,
+ FormControl,
+ FormLabel,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+ Spacer,
+ Textarea,
+} from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { workflowLoadRequested } from 'features/nodes/store/actions';
+import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow';
+import { atom } from 'nanostores';
+import type { ChangeEvent } from 'react';
+import { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const $isOpen = atom(false);
+
+export const useLoadWorkflowFromGraphModal = () => {
+ const isOpen = useStore($isOpen);
+ const onOpen = useCallback(() => {
+ $isOpen.set(true);
+ }, []);
+ const onClose = useCallback(() => {
+ $isOpen.set(false);
+ }, []);
+
+ return { isOpen, onOpen, onClose };
+};
+
+export const LoadWorkflowFromGraphModal = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const { isOpen, onClose } = useLoadWorkflowFromGraphModal();
+ const [graphRaw, setGraphRaw] = useState('');
+ const [workflowRaw, setWorkflowRaw] = useState('');
+ const [shouldAutoLayout, setShouldAutoLayout] = useState(true);
+ const onChangeGraphRaw = useCallback((e: ChangeEvent) => {
+ setGraphRaw(e.target.value);
+ }, []);
+ const onChangeWorkflowRaw = useCallback((e: ChangeEvent) => {
+ setWorkflowRaw(e.target.value);
+ }, []);
+ const onChangeShouldAutoLayout = useCallback((e: ChangeEvent) => {
+ setShouldAutoLayout(e.target.checked);
+ }, []);
+ const parse = useCallback(() => {
+ const graph = JSON.parse(graphRaw);
+ const workflow = graphToWorkflow(graph, shouldAutoLayout);
+ setWorkflowRaw(JSON.stringify(workflow, null, 2));
+ }, [graphRaw, shouldAutoLayout]);
+ const loadWorkflow = useCallback(() => {
+ const workflow = JSON.parse(workflowRaw);
+ dispatch(workflowLoadRequested({ workflow, asCopy: true }));
+ onClose();
+ }, [dispatch, onClose, workflowRaw]);
+ return (
+
+
+
+ {t('workflows.loadFromGraph')}
+
+
+
+
+
+ {t('workflows.autoLayout')}
+
+
+
+
+
+
+ {t('nodes.graph')}
+
+
+
+ {t('nodes.workflow')}
+
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/LoadWorkflowFromGraphMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/LoadWorkflowFromGraphMenuItem.tsx
new file mode 100644
index 0000000000..8f3cb0c6f6
--- /dev/null
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/LoadWorkflowFromGraphMenuItem.tsx
@@ -0,0 +1,18 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useLoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFlaskBold } from 'react-icons/pi';
+
+const LoadWorkflowFromGraphMenuItem = () => {
+ const { t } = useTranslation();
+ const { onOpen } = useLoadWorkflowFromGraphModal();
+
+ return (
+ } onClick={onOpen}>
+ {t('workflows.loadFromGraph')}
+
+ );
+};
+
+export default memo(LoadWorkflowFromGraphMenuItem);
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx
index 55d8ac2626..73e9f5d4ba 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx
@@ -6,8 +6,10 @@ import {
MenuList,
useDisclosure,
useGlobalMenuClose,
+ useShiftModifier,
} from '@invoke-ai/ui-library';
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
+import LoadWorkflowFromGraphMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/LoadWorkflowFromGraphMenuItem';
import { NewWorkflowMenuItem } from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
import SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem';
@@ -20,6 +22,7 @@ import { PiDotsThreeOutlineFill } from 'react-icons/pi';
const WorkflowLibraryMenu = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
+ const shift = useShiftModifier();
useGlobalMenuClose(onClose);
return (
);