diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 59cf87fbda..0640ab9ef0 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -102,8 +102,7 @@ "openInNewTab": "Open in New Tab", "dontAskMeAgain": "Don't ask me again", "areYouSure": "Are you sure?", - "imagePrompt": "Image Prompt", - "clearNodes": "Are you sure you want to clear all nodes?" + "imagePrompt": "Image Prompt" }, "gallery": { "generations": "Generations", @@ -615,6 +614,11 @@ "initialImageNotSetDesc": "Could not load initial image", "nodesSaved": "Nodes Saved", "nodesLoaded": "Nodes Loaded", + "nodesNotValidGraph": "Not a valid InvokeAI Node Graph", + "nodesNotValidJSON": "Not a valid JSON", + "nodesCorruptedGraph": "Cannot load. Graph seems to be corrupted.", + "nodesUnrecognizedTypes": "Cannot load. Graph has unrecognized types", + "nodesBrokenConnections": "Cannot load. Some connections are broken.", "nodesLoadedFailed": "Failed To Load Nodes", "nodesCleared": "Nodes Cleared" }, @@ -700,9 +704,10 @@ }, "nodes": { "reloadSchema": "Reload Schema", - "saveNodes": "Save Nodes", - "loadNodes": "Load Nodes", - "clearNodes": "Clear Nodes", + "saveGraph": "Save Graph", + "loadGraph": "Load Graph (saved from Node Editor) (Do not copy-paste metadata)", + "clearGraph": "Clear Graph", + "clearGraphDesc": "Are you sure you want to clear all nodes?", "zoomInNodes": "Zoom In", "zoomOutNodes": "Zoom Out", "fitViewportNodes": "Fit View", diff --git a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx index 90f8039285..21076e16f5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx @@ -2,11 +2,11 @@ import { HStack } from '@chakra-ui/react'; import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton'; import { memo } from 'react'; import { Panel } from 'reactflow'; -import LoadNodesButton from '../ui/LoadNodesButton'; +import ClearGraphButton from '../ui/ClearGraphButton'; +import LoadGraphButton from '../ui/LoadGraphButton'; import NodeInvokeButton from '../ui/NodeInvokeButton'; import ReloadSchemaButton from '../ui/ReloadSchemaButton'; -import SaveNodesButton from '../ui/SaveNodesButton'; -import ClearNodesButton from '../ui/ClearNodesButton'; +import SaveGraphButton from '../ui/SaveGraphButton'; const TopCenterPanel = () => { return ( @@ -15,9 +15,9 @@ const TopCenterPanel = () => { - - - + + + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/ClearNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/ClearGraphButton.tsx similarity index 87% rename from invokeai/frontend/web/src/features/nodes/components/ui/ClearNodesButton.tsx rename to invokeai/frontend/web/src/features/nodes/components/ui/ClearGraphButton.tsx index 86d9d08a84..432675c5cd 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/ClearNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/ClearGraphButton.tsx @@ -9,17 +9,17 @@ import { Text, useDisclosure, } from '@chakra-ui/react'; -import { makeToast } from 'features/system/util/makeToast'; import { RootState } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { addToast } from 'features/system/store/systemSlice'; -import { memo, useRef, useCallback } from 'react'; +import { makeToast } from 'features/system/util/makeToast'; +import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { FaTrash } from 'react-icons/fa'; -const ClearNodesButton = () => { +const ClearGraphButton = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -46,8 +46,8 @@ const ClearNodesButton = () => { <> } - tooltip={t('nodes.clearNodes')} - aria-label={t('nodes.clearNodes')} + tooltip={t('nodes.clearGraph')} + aria-label={t('nodes.clearGraph')} onClick={onOpen} isDisabled={nodes.length === 0} /> @@ -62,11 +62,11 @@ const ClearNodesButton = () => { - {t('nodes.clearNodes')} + {t('nodes.clearGraph')} - {t('common.clearNodes')} + {t('nodes.clearGraphDesc')} @@ -83,4 +83,4 @@ const ClearNodesButton = () => { ); }; -export default memo(ClearNodesButton); +export default memo(ClearGraphButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/LoadGraphButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/LoadGraphButton.tsx new file mode 100644 index 0000000000..44d93bb8fe --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/LoadGraphButton.tsx @@ -0,0 +1,161 @@ +import { FileButton } from '@mantine/core'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { loadFileEdges, loadFileNodes } from 'features/nodes/store/nodesSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { makeToast } from 'features/system/util/makeToast'; +import i18n from 'i18n'; +import { memo, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaUpload } from 'react-icons/fa'; +import { useReactFlow } from 'reactflow'; + +interface JsonFile { + [key: string]: unknown; +} + +function sanityCheckInvokeAIGraph(jsonFile: JsonFile): { + isValid: boolean; + message: string; +} { + // Check if primary keys exist + const keys = ['nodes', 'edges', 'viewport']; + for (const key of keys) { + if (!(key in jsonFile)) { + return { + isValid: false, + message: i18n.t('toast.nodesNotValidGraph'), + }; + } + } + + // Check if nodes and edges are arrays + if (!Array.isArray(jsonFile.nodes) || !Array.isArray(jsonFile.edges)) { + return { + isValid: false, + message: i18n.t('toast.nodesNotValidGraph'), + }; + } + + // Check if data is present in nodes + const nodeKeys = ['data', 'type']; + const nodeTypes = ['invocation', 'progress_image']; + if (jsonFile.nodes.length > 0) { + for (const node of jsonFile.nodes) { + for (const nodeKey of nodeKeys) { + if (!(nodeKey in node)) { + return { + isValid: false, + message: i18n.t('toast.nodesNotValidGraph'), + }; + } + if (nodeKey === 'type' && !nodeTypes.includes(node[nodeKey])) { + return { + isValid: false, + message: i18n.t('toast.nodesUnrecognizedTypes'), + }; + } + } + } + } + + // Check Edge Object + const edgeKeys = ['source', 'sourceHandle', 'target', 'targetHandle']; + if (jsonFile.edges.length > 0) { + for (const edge of jsonFile.edges) { + for (const edgeKey of edgeKeys) { + if (!(edgeKey in edge)) { + return { + isValid: false, + message: i18n.t('toast.nodesBrokenConnections'), + }; + } + } + } + } + + return { + isValid: true, + message: i18n.t('toast.nodesLoaded'), + }; +} + +const LoadGraphButton = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { fitView } = useReactFlow(); + + const uploadedFileRef = useRef<() => void>(null); + + const restoreJSONToEditor = useCallback( + (v: File | null) => { + if (!v) return; + const reader = new FileReader(); + reader.onload = async () => { + const json = reader.result; + + try { + const retrievedNodeTree = await JSON.parse(String(json)); + const { isValid, message } = + sanityCheckInvokeAIGraph(retrievedNodeTree); + + if (isValid) { + dispatch(loadFileNodes(retrievedNodeTree.nodes)); + dispatch(loadFileEdges(retrievedNodeTree.edges)); + fitView(); + + dispatch( + addToast(makeToast({ title: message, status: 'success' })) + ); + } else { + dispatch( + addToast( + makeToast({ + title: message, + status: 'error', + }) + ) + ); + } + // Cleanup + reader.abort(); + } catch (error) { + if (error) { + dispatch( + addToast( + makeToast({ + title: t('toast.nodesNotValidJSON'), + status: 'error', + }) + ) + ); + } + } + }; + + reader.readAsText(v); + + // Cleanup + uploadedFileRef.current?.(); + }, + [fitView, dispatch, t] + ); + return ( + + {(props) => ( + } + tooltip={t('nodes.loadGraph')} + aria-label={t('nodes.loadGraph')} + {...props} + /> + )} + + ); +}; + +export default memo(LoadGraphButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx deleted file mode 100644 index 706fbd8b31..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { FileButton } from '@mantine/core'; -import { makeToast } from 'features/system/util/makeToast'; -import { useAppDispatch } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { loadFileEdges, loadFileNodes } from 'features/nodes/store/nodesSlice'; -import { addToast } from 'features/system/store/systemSlice'; -import { memo, useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FaUpload } from 'react-icons/fa'; -import { useReactFlow } from 'reactflow'; - -const LoadNodesButton = () => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const { fitView } = useReactFlow(); - - const uploadedFileRef = useRef<() => void>(null); - - const restoreJSONToEditor = useCallback( - (v: File | null) => { - if (!v) return; - const reader = new FileReader(); - reader.onload = async () => { - const json = reader.result; - const retrievedNodeTree = await JSON.parse(String(json)); - - if (!retrievedNodeTree) { - dispatch( - addToast( - makeToast({ - title: t('toast.nodesLoadedFailed'), - status: 'error', - }) - ) - ); - } - - if (retrievedNodeTree) { - dispatch(loadFileNodes(retrievedNodeTree.nodes)); - dispatch(loadFileEdges(retrievedNodeTree.edges)); - fitView(); - - dispatch( - addToast( - makeToast({ title: t('toast.nodesLoaded'), status: 'success' }) - ) - ); - } - - // Cleanup - reader.abort(); - }; - - reader.readAsText(v); - - // Cleanup - uploadedFileRef.current?.(); - }, - [fitView, dispatch, t] - ); - return ( - - {(props) => ( - } - tooltip={t('nodes.loadNodes')} - aria-label={t('nodes.loadNodes')} - {...props} - /> - )} - - ); -}; - -export default memo(LoadNodesButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/SaveGraphButton.tsx similarity index 90% rename from invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx rename to invokeai/frontend/web/src/features/nodes/components/ui/SaveGraphButton.tsx index 5833182456..42e545258e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/SaveGraphButton.tsx @@ -6,7 +6,7 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FaSave } from 'react-icons/fa'; -const SaveNodesButton = () => { +const SaveGraphButton = () => { const { t } = useTranslation(); const editorInstance = useAppSelector( (state: RootState) => state.nodes.editorInstance @@ -37,12 +37,12 @@ const SaveNodesButton = () => { } fontSize={18} - tooltip={t('nodes.saveNodes')} - aria-label={t('nodes.saveNodes')} + tooltip={t('nodes.saveGraph')} + aria-label={t('nodes.saveGraph')} onClick={saveEditorToJSON} isDisabled={nodes.length === 0} /> ); }; -export default memo(SaveNodesButton); +export default memo(SaveGraphButton);