mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
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.
This commit is contained in:
parent
dca30d5462
commit
b58494c420
@ -52,6 +52,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react-use-size": "^2.1.0",
|
"@chakra-ui/react-use-size": "^2.1.0",
|
||||||
|
"@dagrejs/dagre": "^1.1.1",
|
||||||
"@dagrejs/graphlib": "^2.2.1",
|
"@dagrejs/graphlib": "^2.2.1",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
@ -11,6 +11,9 @@ dependencies:
|
|||||||
'@chakra-ui/react-use-size':
|
'@chakra-ui/react-use-size':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0(react@18.2.0)
|
version: 2.1.0(react@18.2.0)
|
||||||
|
'@dagrejs/dagre':
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1
|
||||||
'@dagrejs/graphlib':
|
'@dagrejs/graphlib':
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.2.1
|
version: 2.2.1
|
||||||
@ -3092,6 +3095,12 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: 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:
|
/@dagrejs/graphlib@2.2.1:
|
||||||
resolution: {integrity: sha512-xJsN1v6OAxXk6jmNdM+OS/bBE8nDCwM0yDNprXR18ZNatL6to9ggod9+l2XtiLhXfLm0NkE7+Er/cpdlM+SkUA==}
|
resolution: {integrity: sha512-xJsN1v6OAxXk6jmNdM+OS/bBE8nDCwM0yDNprXR18ZNatL6to9ggod9+l2XtiLhXfLm0NkE7+Er/cpdlM+SkUA==}
|
||||||
engines: {node: '>17.0.0'}
|
engines: {node: '>17.0.0'}
|
||||||
|
@ -849,6 +849,7 @@
|
|||||||
"version": "Version",
|
"version": "Version",
|
||||||
"versionUnknown": " Version Unknown",
|
"versionUnknown": " Version Unknown",
|
||||||
"workflow": "Workflow",
|
"workflow": "Workflow",
|
||||||
|
"graph": "Graph",
|
||||||
"workflowAuthor": "Author",
|
"workflowAuthor": "Author",
|
||||||
"workflowContact": "Contact",
|
"workflowContact": "Contact",
|
||||||
"workflowDescription": "Short Description",
|
"workflowDescription": "Short Description",
|
||||||
@ -1482,7 +1483,11 @@
|
|||||||
"workflowName": "Workflow Name",
|
"workflowName": "Workflow Name",
|
||||||
"newWorkflowCreated": "New Workflow Created",
|
"newWorkflowCreated": "New Workflow Created",
|
||||||
"workflowCleared": "Workflow Cleared",
|
"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": {
|
"app": {
|
||||||
"storeNotInitialized": "Store is not initialized"
|
"storeNotInitialized": "Store is not initialized"
|
||||||
|
@ -3,6 +3,7 @@ import 'reactflow/dist/style.css';
|
|||||||
import { Flex } from '@invoke-ai/ui-library';
|
import { Flex } from '@invoke-ai/ui-library';
|
||||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||||
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
|
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 { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
|
||||||
import type { AnimationProps } from 'framer-motion';
|
import type { AnimationProps } from 'framer-motion';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
@ -61,6 +62,7 @@ const NodeEditor = () => {
|
|||||||
<BottomLeftPanel />
|
<BottomLeftPanel />
|
||||||
<MinimapPanel />
|
<MinimapPanel />
|
||||||
<SaveWorkflowAsDialog />
|
<SaveWorkflowAsDialog />
|
||||||
|
<LoadWorkflowFromGraphModal />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
@ -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<string, FieldInputInstance> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
@ -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<boolean>(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<string>('');
|
||||||
|
const [workflowRaw, setWorkflowRaw] = useState<string>('');
|
||||||
|
const [shouldAutoLayout, setShouldAutoLayout] = useState(true);
|
||||||
|
const onChangeGraphRaw = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setGraphRaw(e.target.value);
|
||||||
|
}, []);
|
||||||
|
const onChangeWorkflowRaw = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setWorkflowRaw(e.target.value);
|
||||||
|
}, []);
|
||||||
|
const onChangeShouldAutoLayout = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} isCentered>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent w="80vw" h="80vh" maxW="unset" maxH="unset">
|
||||||
|
<ModalHeader>{t('workflows.loadFromGraph')}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody as={Flex} flexDir="column" gap={4} w="full" h="full" pb={4}>
|
||||||
|
<Flex gap={4}>
|
||||||
|
<Button onClick={parse} size="sm" flexShrink={0}>
|
||||||
|
{t('workflows.convertGraph')}
|
||||||
|
</Button>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>{t('workflows.autoLayout')}</FormLabel>
|
||||||
|
<Checkbox isChecked={shouldAutoLayout} onChange={onChangeShouldAutoLayout} />
|
||||||
|
</FormControl>
|
||||||
|
<Spacer />
|
||||||
|
<Button onClick={loadWorkflow} size="sm" flexShrink={0}>
|
||||||
|
{t('workflows.loadWorkflow')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
<FormControl orientation="vertical" h="50%">
|
||||||
|
<FormLabel>{t('nodes.graph')}</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
h="full"
|
||||||
|
value={graphRaw}
|
||||||
|
fontFamily="monospace"
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
overflowWrap="normal"
|
||||||
|
onChange={onChangeGraphRaw}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl orientation="vertical" h="50%">
|
||||||
|
<FormLabel>{t('nodes.workflow')}</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
h="full"
|
||||||
|
value={workflowRaw}
|
||||||
|
fontFamily="monospace"
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
overflowWrap="normal"
|
||||||
|
onChange={onChangeWorkflowRaw}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
@ -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 (
|
||||||
|
<MenuItem as="button" icon={<PiFlaskBold />} onClick={onOpen}>
|
||||||
|
{t('workflows.loadFromGraph')}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(LoadWorkflowFromGraphMenuItem);
|
@ -6,8 +6,10 @@ import {
|
|||||||
MenuList,
|
MenuList,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
useGlobalMenuClose,
|
useGlobalMenuClose,
|
||||||
|
useShiftModifier,
|
||||||
} from '@invoke-ai/ui-library';
|
} from '@invoke-ai/ui-library';
|
||||||
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
|
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 { NewWorkflowMenuItem } from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
|
||||||
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
|
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
|
||||||
import SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem';
|
import SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem';
|
||||||
@ -20,6 +22,7 @@ import { PiDotsThreeOutlineFill } from 'react-icons/pi';
|
|||||||
const WorkflowLibraryMenu = () => {
|
const WorkflowLibraryMenu = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const shift = useShiftModifier();
|
||||||
useGlobalMenuClose(onClose);
|
useGlobalMenuClose(onClose);
|
||||||
return (
|
return (
|
||||||
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
|
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
|
||||||
@ -38,6 +41,8 @@ const WorkflowLibraryMenu = () => {
|
|||||||
<DownloadWorkflowMenuItem />
|
<DownloadWorkflowMenuItem />
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<SettingsMenuItem />
|
<SettingsMenuItem />
|
||||||
|
{shift && <MenuDivider />}
|
||||||
|
{shift && <LoadWorkflowFromGraphMenuItem />}
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user