diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 94e05f791c..561b577a46 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -920,7 +920,10 @@ "unknownTemplate": "Unknown Template", "unkownInvocation": "Unknown Invocation type", "updateNode": "Update Node", + "updateAllNodes": "Update All Nodes", "updateApp": "Update App", + "unableToUpdateNodes_one": "Unable to update {{count}} node", + "unableToUpdateNodes_other": "Unable to update {{count}} nodes", "vaeField": "Vae", "vaeFieldDescription": "Vae submodel.", "vaeModelField": "VAE", 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 772ea216c0..9c1727fc79 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -72,6 +72,7 @@ import { addStagingAreaImageSavedListener } from './listeners/stagingAreaImageSa import { addTabChangedListener } from './listeners/tabChanged'; import { addUpscaleRequestedListener } from './listeners/upscaleRequested'; import { addWorkflowLoadedListener } from './listeners/workflowLoaded'; +import { addUpdateAllNodesRequestedListener } from './listeners/updateAllNodesRequested'; export const listenerMiddleware = createListenerMiddleware(); @@ -178,6 +179,7 @@ addReceivedOpenAPISchemaListener(); // Workflows addWorkflowLoadedListener(); +addUpdateAllNodesRequestedListener(); // DND addImageDroppedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested.ts new file mode 100644 index 0000000000..ece6702ceb --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested.ts @@ -0,0 +1,52 @@ +import { + getNeedsUpdate, + updateNode, +} from 'features/nodes/hooks/useNodeVersion'; +import { updateAllNodesRequested } from 'features/nodes/store/actions'; +import { nodeReplaced } from 'features/nodes/store/nodesSlice'; +import { startAppListening } from '..'; +import { logger } from 'app/logging/logger'; +import { addToast } from 'features/system/store/systemSlice'; +import { makeToast } from 'features/system/util/makeToast'; +import { t } from 'i18next'; + +export const addUpdateAllNodesRequestedListener = () => { + startAppListening({ + actionCreator: updateAllNodesRequested, + effect: (action, { dispatch, getState }) => { + const log = logger('nodes'); + const nodes = getState().nodes.nodes; + const templates = getState().nodes.nodeTemplates; + + let unableToUpdateCount = 0; + + nodes.forEach((node) => { + const template = templates[node.data.type]; + const needsUpdate = getNeedsUpdate(node, template); + const updatedNode = updateNode(node, template); + if (!updatedNode) { + if (needsUpdate) { + unableToUpdateCount++; + } + return; + } + dispatch(nodeReplaced({ nodeId: updatedNode.id, node: updatedNode })); + }); + + if (unableToUpdateCount) { + log.warn( + `Unable to update ${unableToUpdateCount} nodes. Please report this issue.` + ); + dispatch( + addToast( + makeToast({ + title: t('nodes.unableToUpdateNodes', { + count: unableToUpdateCount, + }), + }) + ) + ); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx index d355eab348..38aa9bbad7 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx @@ -3,15 +3,22 @@ import { useAppDispatch } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice'; import { memo, useCallback } from 'react'; -import { FaPlus } from 'react-icons/fa'; +import { FaPlus, FaSync } from 'react-icons/fa'; import { useTranslation } from 'react-i18next'; +import IAIButton from 'common/components/IAIButton'; +import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate'; +import { updateAllNodesRequested } from 'features/nodes/store/actions'; const TopLeftPanel = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); + const nodesNeedUpdate = useGetNodesNeedUpdate(); const handleOpenAddNodePopover = useCallback(() => { dispatch(addNodePopoverOpened()); }, [dispatch]); + const handleClickUpdateNodes = useCallback(() => { + dispatch(updateAllNodesRequested()); + }, [dispatch]); return ( @@ -21,6 +28,11 @@ const TopLeftPanel = () => { icon={} onClick={handleOpenAddNodePopover} /> + {nodesNeedUpdate && ( + } onClick={handleClickUpdateNodes}> + {t('nodes.updateAllNodes')} + + )} ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx index 9e765ff01e..397d1295b0 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx @@ -107,7 +107,7 @@ const Content = (props: { {props.node.data.version} - {mayUpdate && ( + {needsUpdate && ( { + const nodes = state.nodes.nodes; + const templates = state.nodes.nodeTemplates; + + const needsUpdate = nodes.some((node) => { + const template = templates[node.data.type]; + return getNeedsUpdate(node, template); + }); + return needsUpdate; + }, + defaultSelectorOptions +); + +export const useGetNodesNeedUpdate = () => { + const getNeedsUpdate = useAppSelector(selector); + return getNeedsUpdate; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts index 60192de61d..1f213d6481 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts @@ -3,20 +3,80 @@ import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { satisfies } from 'compare-versions'; +import { cloneDeep, defaultsDeep } from 'lodash-es'; import { useCallback, useMemo } from 'react'; +import { Node } from 'reactflow'; +import { AnyInvocationType } from 'services/events/types'; +import { nodeReplaced } from '../store/nodesSlice'; +import { buildNodeData } from '../store/util/buildNodeData'; import { InvocationNodeData, + InvocationTemplate, + NodeData, isInvocationNode, zParsedSemver, } from '../types/types'; -import { cloneDeep, defaultsDeep } from 'lodash-es'; -import { buildNodeData } from '../store/util/buildNodeData'; -import { AnyInvocationType } from 'services/events/types'; -import { Node } from 'reactflow'; -import { nodeReplaced } from '../store/nodesSlice'; +import { useAppToaster } from 'app/components/Toaster'; +import { useTranslation } from 'react-i18next'; + +export const getNeedsUpdate = ( + node?: Node, + template?: InvocationTemplate +) => { + if (!isInvocationNode(node) || !template) { + return false; + } + return node.data.version !== template.version; +}; + +export const getMayUpdateNode = ( + node?: Node, + template?: InvocationTemplate +) => { + const needsUpdate = getNeedsUpdate(node, template); + if ( + !needsUpdate || + !isInvocationNode(node) || + !template || + !node.data.version + ) { + return false; + } + const templateMajor = zParsedSemver.parse(template.version).major; + + return satisfies(node.data.version, `^${templateMajor}`); +}; + +export const updateNode = ( + node?: Node, + template?: InvocationTemplate +) => { + const mayUpdate = getMayUpdateNode(node, template); + if ( + !mayUpdate || + !isInvocationNode(node) || + !template || + !node.data.version + ) { + return; + } + + const defaults = buildNodeData( + node.data.type as AnyInvocationType, + node.position, + template + ) as Node; + + const clone = cloneDeep(node); + clone.data.version = template.version; + defaultsDeep(clone, defaults); + return clone; +}; export const useNodeVersion = (nodeId: string) => { const dispatch = useAppDispatch(); + const toast = useAppToaster(); + const { t } = useTranslation(); const selector = useMemo( () => createSelector( @@ -33,48 +93,27 @@ export const useNodeVersion = (nodeId: string) => { const { node, nodeTemplate } = useAppSelector(selector); - const needsUpdate = useMemo(() => { - if (!isInvocationNode(node) || !nodeTemplate) { - return false; - } - return node.data.version !== nodeTemplate.version; - }, [node, nodeTemplate]); + const needsUpdate = useMemo( + () => getNeedsUpdate(node, nodeTemplate), + [node, nodeTemplate] + ); - const mayUpdate = useMemo(() => { - if ( - !needsUpdate || - !isInvocationNode(node) || - !nodeTemplate || - !node.data.version - ) { - return false; - } - const templateMajor = zParsedSemver.parse(nodeTemplate.version).major; + const mayUpdate = useMemo( + () => getMayUpdateNode(node, nodeTemplate), + [node, nodeTemplate] + ); - return satisfies(node.data.version, `^${templateMajor}`); - }, [needsUpdate, node, nodeTemplate]); - - const updateNode = useCallback(() => { - if ( - !mayUpdate || - !isInvocationNode(node) || - !nodeTemplate || - !node.data.version - ) { + const _updateNode = useCallback(() => { + const needsUpdate = getNeedsUpdate(node, nodeTemplate); + const updatedNode = updateNode(node, nodeTemplate); + if (!updatedNode) { + if (needsUpdate) { + toast({ title: t('nodes.unableToUpdateNodes', { count: 1 }) }); + } return; } + dispatch(nodeReplaced({ nodeId: updatedNode.id, node: updatedNode })); + }, [dispatch, node, nodeTemplate, t, toast]); - const defaults = buildNodeData( - node.data.type as AnyInvocationType, - node.position, - nodeTemplate - ) as Node; - - const clone = cloneDeep(node); - clone.data.version = nodeTemplate.version; - defaultsDeep(clone, defaults); - dispatch(nodeReplaced({ nodeId: clone.id, node: clone })); - }, [dispatch, mayUpdate, node, nodeTemplate]); - - return { needsUpdate, mayUpdate, updateNode }; + return { needsUpdate, mayUpdate, updateNode: _updateNode }; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/actions.ts b/invokeai/frontend/web/src/features/nodes/store/actions.ts index cf7ccf8238..0d75e6934d 100644 --- a/invokeai/frontend/web/src/features/nodes/store/actions.ts +++ b/invokeai/frontend/web/src/features/nodes/store/actions.ts @@ -21,3 +21,7 @@ export const isAnyGraphBuilt = isAnyOf( export const workflowLoadRequested = createAction( 'nodes/workflowLoadRequested' ); + +export const updateAllNodesRequested = createAction( + 'nodes/updateAllNodesRequested' +);