From c3b2a8cb2755c1456746c03c9935a38970ad1503 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Thu, 8 Feb 2024 22:37:52 -0500 Subject: [PATCH 1/8] Quick Seamless Fixes --- invokeai/backend/model_management/seamless.py | 87 ++++++++----------- 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/invokeai/backend/model_management/seamless.py b/invokeai/backend/model_management/seamless.py index bfdf9e0c53..3ab2db1d90 100644 --- a/invokeai/backend/model_management/seamless.py +++ b/invokeai/backend/model_management/seamless.py @@ -28,68 +28,49 @@ def _conv_forward_asymmetric(self, input, weight, bias): def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL], seamless_axes: List[str]): try: to_restore = [] + skipped_layers = 0 + skip_second_resnet = True + skip_conv2 = True for m_name, m in model.named_modules(): - if isinstance(model, UNet2DConditionModel): - if ".attentions." in m_name: + if not isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)): + continue + + if isinstance(model, UNet2DConditionModel) and m_name.startswith("down_blocks.") and ".resnets." in m_name: + # down_blocks.1.resnets.1.conv1 + _, block_num, _, resnet_num, submodule_name = m_name.split(".") + block_num = int(block_num) + resnet_num = int(resnet_num) + + # if block_num >= seamless_down_blocks: + if block_num >= len(model.down_blocks) - skipped_layers: continue - if ".resnets." in m_name: - if ".conv2" in m_name: - continue - if ".conv_shortcut" in m_name: - continue - - """ - if isinstance(model, UNet2DConditionModel): - if False and ".upsamplers." in m_name: + if resnet_num > 0 and skip_second_resnet: continue - if False and ".downsamplers." in m_name: + if submodule_name == "conv2" and skip_conv2: continue - if True and ".resnets." in m_name: - if True and ".conv1" in m_name: - if False and "down_blocks" in m_name: - continue - if False and "mid_block" in m_name: - continue - if False and "up_blocks" in m_name: - continue + m.asymmetric_padding_mode = {} + m.asymmetric_padding = {} + m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant" + m.asymmetric_padding["x"] = ( + m._reversed_padding_repeated_twice[0], + m._reversed_padding_repeated_twice[1], + 0, + 0, + ) + m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant" + m.asymmetric_padding["y"] = ( + 0, + 0, + m._reversed_padding_repeated_twice[2], + m._reversed_padding_repeated_twice[3], + ) - if True and ".conv2" in m_name: - continue - - if True and ".conv_shortcut" in m_name: - continue - - if True and ".attentions." in m_name: - continue - - if False and m_name in ["conv_in", "conv_out"]: - continue - """ - - if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)): - m.asymmetric_padding_mode = {} - m.asymmetric_padding = {} - m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant" - m.asymmetric_padding["x"] = ( - m._reversed_padding_repeated_twice[0], - m._reversed_padding_repeated_twice[1], - 0, - 0, - ) - m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant" - m.asymmetric_padding["y"] = ( - 0, - 0, - m._reversed_padding_repeated_twice[2], - m._reversed_padding_repeated_twice[3], - ) - - to_restore.append((m, m._conv_forward)) - m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d) + to_restore.append((m, m._conv_forward)) + m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d) yield From 3339ad4df80f250c0f19779fdb8561f0353c5fc4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 13 Feb 2024 13:34:06 +1100 Subject: [PATCH 2/8] feat(nodes): seamless.py minor cleanup --- invokeai/backend/model_management/seamless.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/invokeai/backend/model_management/seamless.py b/invokeai/backend/model_management/seamless.py index 3ab2db1d90..e145c6f481 100644 --- a/invokeai/backend/model_management/seamless.py +++ b/invokeai/backend/model_management/seamless.py @@ -1,10 +1,11 @@ from __future__ import annotations from contextlib import contextmanager -from typing import List, Union +from typing import Callable, List, Union import torch.nn as nn -from diffusers.models import AutoencoderKL, UNet2DConditionModel +from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL +from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel def _conv_forward_asymmetric(self, input, weight, bias): @@ -26,12 +27,9 @@ def _conv_forward_asymmetric(self, input, weight, bias): @contextmanager def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL], seamless_axes: List[str]): + # Callable: (input: Tensor, weight: Tensor, bias: Optional[Tensor]) -> Tensor + to_restore: list[tuple[nn.Conv2d | nn.ConvTranspose2d, Callable]] = [] try: - to_restore = [] - skipped_layers = 0 - skip_second_resnet = True - skip_conv2 = True - for m_name, m in model.named_modules(): if not isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)): continue @@ -42,14 +40,16 @@ def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL], seamless_axe block_num = int(block_num) resnet_num = int(resnet_num) - # if block_num >= seamless_down_blocks: - if block_num >= len(model.down_blocks) - skipped_layers: + # Could be configurable to allow skipping arbitrary numbers of down blocks + if block_num >= len(model.down_blocks): continue - if resnet_num > 0 and skip_second_resnet: + # Skip the second resnet (could be configurable) + if resnet_num > 0: continue - if submodule_name == "conv2" and skip_conv2: + # Skip Conv2d layers (could be configurable) + if submodule_name == "conv2": continue m.asymmetric_padding_mode = {} From 3726293258b5c4e062e3c2bef9d2e0304a3ffdae Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:27:31 +1100 Subject: [PATCH 3/8] feat(nodes): improve types in graph.py Methods `get_node` and `complete` were typed as returning a dynamically created unions `InvocationsUnion` and `InvocationOutputsUnion`, respectively. Static type analysers cannot work with dynamic objects, so these methods end up as effectively un-annotated, returning `Unknown`. They now return `BaseInvocation` and `BaseInvocationOutput`, respectively, which are the superclasses of all members of each union. This gives us the best type annotation that is possible. Note: the return types of these methods are never introspected, so it doesn't really matter what they are at runtime. --- invokeai/app/services/shared/graph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index 80f56b49d3..1acf165aba 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -540,7 +540,7 @@ class Graph(BaseModel): except NodeNotFoundError: return False - def get_node(self, node_path: str) -> InvocationsUnion: + def get_node(self, node_path: str) -> BaseInvocation: """Gets a node from the graph using a node path.""" # Materialized graphs may have nodes at the top level graph, node_id = self._get_graph_and_node(node_path) @@ -891,7 +891,7 @@ class GraphExecutionState(BaseModel): # If next is still none, there's no next node, return None return next_node - def complete(self, node_id: str, output: InvocationOutputsUnion): + def complete(self, node_id: str, output: BaseInvocationOutput) -> None: """Marks a node as complete""" if node_id not in self.execution_graph.nodes: From 85bbf65967d14bc270dfbbfd2a18e929007c1d30 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Tue, 13 Feb 2024 12:29:38 -0500 Subject: [PATCH 4/8] only refetch intermediates on modal open if it is enabled --- .../system/components/SettingsModal/SettingsModal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index cc937170a3..b7232ae33f 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -101,9 +101,11 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { const clearStorage = useClearStorage(); const handleOpenSettingsModel = useCallback(() => { - refetchIntermediatesCount(); + if (shouldShowClearIntermediates) { + refetchIntermediatesCount(); + } _onSettingsModalOpen(); - }, [_onSettingsModalOpen, refetchIntermediatesCount]); + }, [_onSettingsModalOpen, refetchIntermediatesCount, shouldShowClearIntermediates]); const handleClickResetWebUI = useCallback(() => { clearStorage(); From 9d6e4ff1fb7ee55024d70e9b11cdca1408d30e60 Mon Sep 17 00:00:00 2001 From: Mary Hipp Rogers Date: Wed, 14 Feb 2024 09:02:07 -0500 Subject: [PATCH 5/8] workflow tab (#5680) * new workflow tab UI - still using shared state with workflow editor tab * polish workflow details * remove workflow tab, add edit/view mode to workflow slice and get that working to switch between within editor tab * UI updates for view/edit mode * cleanup * add warning to view mode * lint * start with isTouched false * working on styling mode toggle * more UX iteration * lint * cleanup * save original field values to state, add indicator if they have been changed and give user choice to reset * lint * fix import and commit translation * dont switch to view mode when loading a workflow * warns before clearing editor * use folder icon * fix(ui): track do not erase value when resetting field value - When adding an exposed field, we need to add it to originalExposedFieldValues - When removing an exposed field, we need to remove it from originalExposedFieldValues - add `useFieldValue` and `useOriginalFieldValue` hooks to encapsulate related logic * feat(ui): use IconButton for workflow view/edit button * feat(ui): change icon for new workflow It was the same as the workflow tab icon, confusing bc you think it's going to somehow take you to the tab. * feat(ui): use render props for NewWorkflowConfirmationAlertDialog There was a lot of potentially sensitive logic shared between the new workflow button and menu items. Also, two instances of ConfirmationAlertDialog. Using a render prop deduplicates the logic & components * fix(ui): do not mark workflow touched when loading workflow This was occurring because the `nodesChanged` action is called by reactflow when loading a workflow. Specifically, it calculates and sets the node dimensions as it loads. The existing logic set `isTouched` whenever this action was called. The changes reactflow emits have types, and we can use the change types and data to determine if a change should result in the workflow being marked as touched. * chore(ui): lint * chore(ui): lint * delete empty file --------- Co-authored-by: Mary Hipp Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com> --- invokeai/frontend/web/public/locales/en.json | 4 + .../listeners/workflowLoadRequested.ts | 2 - .../fields/FieldLinearViewToggle.tsx | 7 +- .../Invocation/fields/LinearViewField.tsx | 15 +++- .../flow/panels/TopPanel/TopPanel.tsx | 14 ++- .../panels/TopPanel/UpdateNodesButton.tsx | 1 + .../flow/panels/TopPanel/WorkflowName.tsx | 15 ---- .../panels/TopRightPanel/TopRightPanel.tsx | 15 ---- .../nodes/components/sidePanel/ModeToggle.tsx | 43 +++++++++ .../sidePanel/NodeEditorPanelGroup.tsx | 58 ++++++++---- .../components/sidePanel/WorkflowMenu.tsx | 26 ++++++ .../components/sidePanel/WorkflowName.tsx | 37 ++++++++ .../sidePanel/viewMode/WorkflowField.tsx | 53 +++++++++++ .../viewMode/WorkflowInfoTooltipContent.tsx | 68 ++++++++++++++ .../sidePanel/viewMode/WorkflowViewMode.tsx | 39 ++++++++ .../sidePanel/viewMode/WorkflowWarning.tsx | 21 +++++ .../viewMode/WorkflowWarningTooltip.tsx | 20 +++++ .../sidePanel/workflow/WorkflowPanel.tsx | 6 +- .../nodes/hooks/useFieldOriginalValue.ts | 28 ++++++ .../src/features/nodes/hooks/useFieldValue.ts | 23 +++++ .../src/features/nodes/store/nodesSlice.ts | 7 +- .../web/src/features/nodes/store/types.ts | 9 +- .../src/features/nodes/store/workflowSlice.ts | 88 ++++++++++++++++--- .../features/ui/components/tabs/NodesTab.tsx | 25 ++++-- .../components/NewWorkflowButton.tsx | 26 ++++++ .../NewWorkflowConfirmationAlertDialog.tsx | 63 +++++++++++++ .../components/WorkflowLibraryButton.tsx | 4 +- .../NewWorkflowMenuItem.tsx | 64 +++----------- .../WorkflowLibraryMenu.tsx | 2 +- 29 files changed, 649 insertions(+), 134 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/ModeToggle.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowMenu.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowName.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowInfoTooltipContent.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowViewMode.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarning.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarningTooltip.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useFieldOriginalValue.ts create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useFieldValue.ts create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0fe833db39..f991714c24 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -175,6 +175,7 @@ "statusUpscaling": "Upscaling", "statusUpscalingESRGAN": "Upscaling (ESRGAN)", "template": "Template", + "toResolve": "To resolve", "training": "Training", "trainingDesc1": "A dedicated workflow for training your own embeddings and checkpoints using Textual Inversion and Dreambooth from the web interface.", "trainingDesc2": "InvokeAI already supports training custom embeddourings using Textual Inversion using the main script.", @@ -900,6 +901,7 @@ "doesNotExist": "does not exist", "downloadWorkflow": "Download Workflow JSON", "edge": "Edge", + "editMode": "Edit in Workflow Editor", "enum": "Enum", "enumDescription": "Enums are values that may be one of a number of options.", "executionStateCompleted": "Completed", @@ -995,6 +997,7 @@ "problemReadingMetadata": "Problem reading metadata from image", "problemReadingWorkflow": "Problem reading workflow from image", "problemSettingTitle": "Problem Setting Title", + "resetToDefaultValue": "Reset to default value", "reloadNodeTemplates": "Reload Node Templates", "removeLinearView": "Remove from Linear View", "newWorkflow": "New Workflow", @@ -1067,6 +1070,7 @@ "vaeModelFieldDescription": "TODO", "validateConnections": "Validate Connections and Graph", "validateConnectionsHelp": "Prevent invalid connections from being made, and invalid graphs from being invoked", + "viewMode": "Use in Linear View", "unableToGetWorkflowVersion": "Unable to get workflow schema version", "unrecognizedWorkflowVersion": "Unrecognized workflow schema version {{version}}", "version": "Version", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts index 0b37271be7..9307031e6d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts @@ -6,7 +6,6 @@ import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/typ import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'; import { addToast } from 'features/system/store/systemSlice'; import { makeToast } from 'features/system/util/makeToast'; -import { setActiveTab } from 'features/ui/store/uiSlice'; import { t } from 'i18next'; import { z } from 'zod'; import { fromZodError } from 'zod-validation-error'; @@ -53,7 +52,6 @@ export const addWorkflowLoadRequestedListener = () => { }); } - dispatch(setActiveTab('nodes')); requestAnimationFrame(() => { $flow.get()?.fitView(); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldLinearViewToggle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldLinearViewToggle.tsx index cb1bcba1fe..ff59e02916 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldLinearViewToggle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldLinearViewToggle.tsx @@ -1,6 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useFieldValue } from 'features/nodes/hooks/useFieldValue'; import { selectWorkflowSlice, workflowExposedFieldAdded, @@ -18,7 +19,7 @@ type Props = { const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - + const value = useFieldValue(nodeId, fieldName); const selectIsExposed = useMemo( () => createSelector(selectWorkflowSlice, (workflow) => { @@ -30,8 +31,8 @@ const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => { const isExposed = useAppSelector(selectIsExposed); const handleExposeField = useCallback(() => { - dispatch(workflowExposedFieldAdded({ nodeId, fieldName })); - }, [dispatch, fieldName, nodeId]); + dispatch(workflowExposedFieldAdded({ nodeId, fieldName, value })); + }, [dispatch, fieldName, nodeId, value]); const handleUnexposeField = useCallback(() => { dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx index 3ec4ab1b42..b3c563b1d3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx @@ -1,12 +1,13 @@ import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay'; +import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue'; import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi'; +import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi'; import EditableFieldTitle from './EditableFieldTitle'; import FieldTooltipContent from './FieldTooltipContent'; @@ -19,8 +20,10 @@ type Props = { const LinearViewField = ({ nodeId, fieldName }: Props) => { const dispatch = useAppDispatch(); + const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName); const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId); const { t } = useTranslation(); + const handleRemoveField = useCallback(() => { dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); }, [dispatch, fieldName, nodeId]); @@ -39,6 +42,16 @@ const LinearViewField = ({ nodeId, fieldName }: Props) => { + {isValueChanged && ( + } + /> + )} } openDelay={HANDLE_TOOLTIP_OPEN_DELAY} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx index c87af124bf..a78024074c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx @@ -1,25 +1,23 @@ import { Flex, Spacer } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton'; import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton'; import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton'; import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton'; -import WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName'; -import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; +import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName'; import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; import { memo } from 'react'; const TopCenterPanel = () => { + const name = useAppSelector((s) => s.workflow.name); return ( - - - - - + + - + {!!name.length && } diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx index d356eaa4e1..9fa710bfb5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx @@ -25,6 +25,7 @@ const UpdateNodesButton = () => { icon={} onClick={handleClickUpdateNodes} pointerEvents="auto" + colorScheme="warning" /> ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx deleted file mode 100644 index 527147c67d..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Text } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { memo } from 'react'; - -const TopCenterPanel = () => { - const name = useAppSelector((s) => s.workflow.name); - - return ( - - {name} - - ); -}; - -export default memo(TopCenterPanel); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx deleted file mode 100644 index be939f35bd..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Flex } from '@invoke-ai/ui-library'; -import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; -import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; -import { memo } from 'react'; - -const TopRightPanel = () => { - return ( - - - - - ); -}; - -export default memo(TopRightPanel); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/ModeToggle.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/ModeToggle.tsx new file mode 100644 index 0000000000..555070d673 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/ModeToggle.tsx @@ -0,0 +1,43 @@ +import { Flex, IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { workflowModeChanged } from 'features/nodes/store/workflowSlice'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiEyeBold, PiPencilBold } from 'react-icons/pi'; + +export const ModeToggle = () => { + const dispatch = useAppDispatch(); + const mode = useAppSelector((s) => s.workflow.mode); + const { t } = useTranslation(); + + const onClickEdit = useCallback(() => { + dispatch(workflowModeChanged('edit')); + }, [dispatch]); + + const onClickView = useCallback(() => { + dispatch(workflowModeChanged('view')); + }, [dispatch]); + + return ( + + {mode === 'view' && ( + } + colorScheme="invokeBlue" + /> + )} + {mode === 'edit' && ( + } + colorScheme="invokeBlue" + /> + )} + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/NodeEditorPanelGroup.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/NodeEditorPanelGroup.tsx index c145003c95..abd3d707d7 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/NodeEditorPanelGroup.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/NodeEditorPanelGroup.tsx @@ -1,22 +1,37 @@ import 'reactflow/dist/style.css'; import { Flex } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; import QueueControls from 'features/queue/components/QueueControls'; import ResizeHandle from 'features/ui/components/tabs/ResizeHandle'; import { usePanelStorage } from 'features/ui/hooks/usePanelStorage'; +import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; import type { CSSProperties } from 'react'; import { memo, useCallback, useRef } from 'react'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels'; import InspectorPanel from './inspector/InspectorPanel'; +import { WorkflowViewMode } from './viewMode/WorkflowViewMode'; import WorkflowPanel from './workflow/WorkflowPanel'; +import { WorkflowMenu } from './WorkflowMenu'; +import { WorkflowName } from './WorkflowName'; const panelGroupStyles: CSSProperties = { height: '100%', width: '100%' }; +const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { + return { + mode: workflow.mode, + }; +}); + const NodeEditorPanelGroup = () => { + const { mode } = useAppSelector(selector); const panelGroupRef = useRef(null); const panelStorage = usePanelStorage(); + const handleDoubleClickHandle = useCallback(() => { if (!panelGroupRef.current) { return; @@ -27,22 +42,33 @@ const NodeEditorPanelGroup = () => { return ( - - - - - - - - - + + + + + + + + + {mode === 'view' && } + {mode === 'edit' && ( + + + + + + + + + + )} ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowMenu.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowMenu.tsx new file mode 100644 index 0000000000..33dd5dc83f --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowMenu.tsx @@ -0,0 +1,26 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton'; + +import { ModeToggle } from './ModeToggle'; + +const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { + return { + mode: workflow.mode, + }; +}); + +export const WorkflowMenu = () => { + const { mode } = useAppSelector(selector); + + return ( + + {mode === 'edit' && } + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowName.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowName.tsx new file mode 100644 index 0000000000..14852945ab --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowName.tsx @@ -0,0 +1,37 @@ +import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useTranslation } from 'react-i18next'; +import { PiDotOutlineFill } from 'react-icons/pi'; + +import WorkflowInfoTooltipContent from './viewMode/WorkflowInfoTooltipContent'; +import { WorkflowWarning } from './viewMode/WorkflowWarning'; + +export const WorkflowName = () => { + const { name, isTouched, mode } = useAppSelector((s) => s.workflow); + const { t } = useTranslation(); + + return ( + + {name.length ? ( + } placement="top"> + + {name} + + + ) : ( + + {t('workflows.unnamedWorkflow')} + + )} + + {isTouched && mode === 'edit' && ( + + + + + + )} + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx new file mode 100644 index 0000000000..0e5857933a --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx @@ -0,0 +1,53 @@ +import { Flex, FormLabel, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library'; +import FieldTooltipContent from 'features/nodes/components/flow/nodes/Invocation/fields/FieldTooltipContent'; +import InputFieldRenderer from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer'; +import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel'; +import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue'; +import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle'; +import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; +import { t } from 'i18next'; +import { memo } from 'react'; +import { PiArrowCounterClockwiseBold, PiInfoBold } from 'react-icons/pi'; + +type Props = { + nodeId: string; + fieldName: string; +}; + +const WorkflowField = ({ nodeId, fieldName }: Props) => { + const label = useFieldLabel(nodeId, fieldName); + const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, 'input'); + const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName); + + return ( + + + {label || fieldTemplateTitle} + + + {isValueChanged && ( + } + /> + )} + } + openDelay={HANDLE_TOOLTIP_OPEN_DELAY} + placement="top" + > + + + + + + + + ); +}; + +export default memo(WorkflowField); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowInfoTooltipContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowInfoTooltipContent.tsx new file mode 100644 index 0000000000..1145af032e --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowInfoTooltipContent.tsx @@ -0,0 +1,68 @@ +import { Box, Flex, Text } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { + return { + name: workflow.name, + description: workflow.description, + notes: workflow.notes, + author: workflow.author, + tags: workflow.tags, + }; +}); + +const WorkflowInfoTooltipContent = () => { + const { name, description, notes, author, tags } = useAppSelector(selector); + const { t } = useTranslation(); + + return ( + + {!!name.length && ( + + {t('nodes.workflowName')} + + {name} + + + )} + {!!author.length && ( + + {t('nodes.workflowAuthor')} + + {author} + + + )} + {!!tags.length && ( + + {t('nodes.workflowTags')} + + {tags} + + + )} + {!!description.length && ( + + {t('nodes.workflowDescription')} + + {description} + + + )} + {!!notes.length && ( + + {t('nodes.workflowNotes')} + + {notes} + + + )} + + ); +}; + +export default memo(WorkflowInfoTooltipContent); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowViewMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowViewMode.tsx new file mode 100644 index 0000000000..7bfe256702 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowViewMode.tsx @@ -0,0 +1,39 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import { t } from 'i18next'; +import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo'; + +import WorkflowField from './WorkflowField'; + +const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { + return { + fields: workflow.exposedFields, + name: workflow.name, + }; +}); + +export const WorkflowViewMode = () => { + const { isLoading } = useGetOpenAPISchemaQuery(); + const { fields } = useAppSelector(selector); + return ( + + + + {isLoading ? ( + + ) : fields.length ? ( + fields.map(({ nodeId, fieldName }) => ( + + )) + ) : ( + + )} + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarning.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarning.tsx new file mode 100644 index 0000000000..d5182ddd62 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarning.tsx @@ -0,0 +1,21 @@ +import { Flex, Icon, Tooltip } from '@invoke-ai/ui-library'; +import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate'; +import { PiWarningBold } from 'react-icons/pi'; + +import { WorkflowWarningTooltip } from './WorkflowWarningTooltip'; + +export const WorkflowWarning = () => { + const nodesNeedUpdate = useGetNodesNeedUpdate(); + + if (!nodesNeedUpdate) { + return <>; + } + + return ( + }> + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarningTooltip.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarningTooltip.tsx new file mode 100644 index 0000000000..7f66d60622 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowWarningTooltip.tsx @@ -0,0 +1,20 @@ +import { Flex, Text } from '@invoke-ai/ui-library'; +import { useTranslation } from 'react-i18next'; + +export const WorkflowWarningTooltip = () => { + const { t } = useTranslation(); + + return ( + + + {t('toast.loadedWithWarnings')} + + {t('common.toResolve')}: + + {t('nodes.editMode')} >> {t('nodes.updateAllNodes')} >> {t('common.save')} + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx index bdc384d40d..cb4a110a89 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx @@ -12,17 +12,17 @@ const WorkflowPanel = () => { - {t('common.linear')} {t('common.details')} + {t('common.linear')} JSON - + - + diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useFieldOriginalValue.ts b/invokeai/frontend/web/src/features/nodes/hooks/useFieldOriginalValue.ts new file mode 100644 index 0000000000..a9ebc991e2 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useFieldOriginalValue.ts @@ -0,0 +1,28 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useFieldValue } from 'features/nodes/hooks/useFieldValue'; +import { fieldValueReset } from 'features/nodes/store/nodesSlice'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import { isEqual } from 'lodash-es'; +import { useCallback, useMemo } from 'react'; + +export const useFieldOriginalValue = (nodeId: string, fieldName: string) => { + const dispatch = useAppDispatch(); + const selectOriginalExposedFieldValues = useMemo( + () => + createSelector( + selectWorkflowSlice, + (workflow) => + workflow.originalExposedFieldValues.find((v) => v.nodeId === nodeId && v.fieldName === fieldName)?.value + ), + [nodeId, fieldName] + ); + const originalValue = useAppSelector(selectOriginalExposedFieldValues); + const value = useFieldValue(nodeId, fieldName); + const isValueChanged = useMemo(() => !isEqual(value, originalValue), [value, originalValue]); + const onReset = useCallback(() => { + dispatch(fieldValueReset({ nodeId, fieldName, value: originalValue })); + }, [dispatch, fieldName, nodeId, originalValue]); + + return { originalValue, isValueChanged, onReset }; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useFieldValue.ts b/invokeai/frontend/web/src/features/nodes/hooks/useFieldValue.ts new file mode 100644 index 0000000000..5b58a7a345 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useFieldValue.ts @@ -0,0 +1,23 @@ +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectNodesSlice } from 'features/nodes/store/nodesSlice'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { useMemo } from 'react'; + +export const useFieldValue = (nodeId: string, fieldName: string) => { + const selector = useMemo( + () => + createMemoizedSelector(selectNodesSlice, (nodes) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return; + } + return node?.data.inputs[fieldName]?.value; + }), + [fieldName, nodeId] + ); + + const value = useAppSelector(selector); + + return value; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 761b273f54..aee01b381b 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -18,6 +18,7 @@ import type { MainModelFieldValue, SchedulerFieldValue, SDXLRefinerModelFieldValue, + StatefulFieldValue, StringFieldValue, T2IAdapterModelFieldValue, VAEModelFieldValue, @@ -36,6 +37,7 @@ import { zMainModelFieldValue, zSchedulerFieldValue, zSDXLRefinerModelFieldValue, + zStatefulFieldValue, zStringFieldValue, zT2IAdapterModelFieldValue, zVAEModelFieldValue, @@ -478,6 +480,9 @@ export const nodesSlice = createSlice({ selectedEdgesChanged: (state, action: PayloadAction) => { state.selectedEdges = action.payload; }, + fieldValueReset: (state, action: FieldValueAction) => { + fieldValueReducer(state, action, zStatefulFieldValue); + }, fieldStringValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zStringFieldValue); }, @@ -760,6 +765,7 @@ export const { edgesChanged, edgesDeleted, edgeUpdated, + fieldValueReset, fieldBoardValueChanged, fieldBooleanValueChanged, fieldColorValueChanged, @@ -834,7 +840,6 @@ export const isAnyNodeOrEdgeMutation = isAnyOf( nodeIsOpenChanged, nodeLabelChanged, nodeNotesChanged, - nodesChanged, nodesDeleted, nodeUseCacheChanged, notesNodeValueChanged, diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index c4a50647b5..8b0de447e4 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -1,4 +1,4 @@ -import type { FieldType } from 'features/nodes/types/field'; +import type { FieldIdentifier, FieldType, StatefulFieldValue } from 'features/nodes/types/field'; import type { AnyNode, InvocationNodeEdge, @@ -33,9 +33,16 @@ export type NodesState = { selectionMode: SelectionMode; }; +export type WorkflowMode = 'edit' | 'view'; +export type FieldIdentifierWithValue = FieldIdentifier & { + value: StatefulFieldValue; +}; + export type WorkflowsState = Omit & { _version: 1; isTouched: boolean; + mode: WorkflowMode; + originalExposedFieldValues: FieldIdentifierWithValue[]; }; export type NodeTemplatesState = { diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index 73802da54e..807025032d 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -2,11 +2,16 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { workflowLoaded } from 'features/nodes/store/actions'; -import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice'; -import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types'; +import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged, nodesDeleted } from 'features/nodes/store/nodesSlice'; +import type { + FieldIdentifierWithValue, + WorkflowMode, + WorkflowsState as WorkflowState, +} from 'features/nodes/store/types'; import type { FieldIdentifier } from 'features/nodes/types/field'; +import { isInvocationNode } from 'features/nodes/types/invocation'; import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow'; -import { cloneDeep, isEqual, uniqBy } from 'lodash-es'; +import { cloneDeep, isEqual, omit, uniqBy } from 'lodash-es'; export const blankWorkflow: Omit = { name: '', @@ -23,7 +28,9 @@ export const blankWorkflow: Omit = { export const initialWorkflowState: WorkflowState = { _version: 1, - isTouched: true, + isTouched: false, + mode: 'view', + originalExposedFieldValues: [], ...blankWorkflow, }; @@ -31,15 +38,25 @@ export const workflowSlice = createSlice({ name: 'workflow', initialState: initialWorkflowState, reducers: { - workflowExposedFieldAdded: (state, action: PayloadAction) => { + workflowModeChanged: (state, action: PayloadAction) => { + state.mode = action.payload; + }, + workflowExposedFieldAdded: (state, action: PayloadAction) => { state.exposedFields = uniqBy( - state.exposedFields.concat(action.payload), + state.exposedFields.concat(omit(action.payload, 'value')), + (field) => `${field.nodeId}-${field.fieldName}` + ); + state.originalExposedFieldValues = uniqBy( + state.originalExposedFieldValues.concat(action.payload), (field) => `${field.nodeId}-${field.fieldName}` ); state.isTouched = true; }, workflowExposedFieldRemoved: (state, action: PayloadAction) => { state.exposedFields = state.exposedFields.filter((field) => !isEqual(field, action.payload)); + state.originalExposedFieldValues = state.originalExposedFieldValues.filter( + (field) => !isEqual(omit(field, 'value'), action.payload) + ); state.isTouched = true; }, workflowNameChanged: (state, action: PayloadAction) => { @@ -78,15 +95,43 @@ export const workflowSlice = createSlice({ workflowIDChanged: (state, action: PayloadAction) => { state.id = action.payload; }, - workflowReset: () => cloneDeep(initialWorkflowState), workflowSaved: (state) => { state.isTouched = false; }, }, extraReducers: (builder) => { builder.addCase(workflowLoaded, (state, action) => { - const { nodes: _nodes, edges: _edges, ...workflowExtra } = action.payload; - return { ...initialWorkflowState, ...cloneDeep(workflowExtra) }; + const { nodes, edges: _edges, ...workflowExtra } = action.payload; + + const originalExposedFieldValues: FieldIdentifierWithValue[] = []; + + workflowExtra.exposedFields.forEach((field) => { + const node = nodes.find((n) => n.id === field.nodeId); + + if (!isInvocationNode(node)) { + return; + } + + const input = node.data.inputs[field.fieldName]; + + if (!input) { + return; + } + + const originalExposedFieldValue = { + nodeId: field.nodeId, + fieldName: field.fieldName, + value: input.value, + }; + originalExposedFieldValues.push(originalExposedFieldValue); + }); + + return { + ...cloneDeep(initialWorkflowState), + ...cloneDeep(workflowExtra), + originalExposedFieldValues, + mode: state.mode, + }; }); builder.addCase(nodesDeleted, (state, action) => { @@ -97,6 +142,29 @@ export const workflowSlice = createSlice({ builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState)); + builder.addCase(nodesChanged, (state, action) => { + // Not all changes to nodes should result in the workflow being marked touched + const filteredChanges = action.payload.filter((change) => { + // We always want to mark the workflow as touched if a node is added, removed, or reset + if (['add', 'remove', 'reset'].includes(change.type)) { + return true; + } + + // Position changes can change the position and the dragging status of the node - ignore if the change doesn't + // affect the position + if (change.type === 'position' && (change.position || change.positionAbsolute)) { + return true; + } + + // This change isn't relevant + return false; + }); + + if (filteredChanges.length > 0) { + state.isTouched = true; + } + }); + builder.addMatcher(isAnyNodeOrEdgeMutation, (state) => { state.isTouched = true; }); @@ -104,6 +172,7 @@ export const workflowSlice = createSlice({ }); export const { + workflowModeChanged, workflowExposedFieldAdded, workflowExposedFieldRemoved, workflowNameChanged, @@ -115,7 +184,6 @@ export const { workflowVersionChanged, workflowContactChanged, workflowIDChanged, - workflowReset, workflowSaved, } = workflowSlice.actions; diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx index 2e2b07f24b..a707327d5d 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/NodesTab.tsx @@ -1,13 +1,28 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay'; import NodeEditor from 'features/nodes/components/NodeEditor'; import { memo } from 'react'; import { ReactFlowProvider } from 'reactflow'; const NodesTab = () => { - return ( - - - - ); + const mode = useAppSelector((s) => s.workflow.mode); + + if (mode === 'edit') { + return ( + + + + ); + } else { + return ( + + + + + + ); + } }; export default memo(NodesTab); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx new file mode 100644 index 0000000000..c1211f9e2b --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx @@ -0,0 +1,26 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFilePlusBold } from 'react-icons/pi'; + +export const NewWorkflowButton = memo(() => { + const { t } = useTranslation(); + + const renderButton = useCallback( + (onClick: () => void) => ( + } + onClick={onClick} + pointerEvents="auto" + /> + ), + [t] + ); + + return ; +}); + +NewWorkflowButton.displayName = 'NewWorkflowButton'; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx new file mode 100644 index 0000000000..b01d259da7 --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx @@ -0,0 +1,63 @@ +import { ConfirmationAlertDialog, Flex, Text, useDisclosure } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { workflowModeChanged } from 'features/nodes/store/workflowSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { makeToast } from 'features/system/util/makeToast'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + renderButton: (onClick: () => void) => JSX.Element; +}; + +export const NewWorkflowConfirmationAlertDialog = memo((props: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const isTouched = useAppSelector((s) => s.workflow.isTouched); + + const handleNewWorkflow = useCallback(() => { + dispatch(nodeEditorReset()); + dispatch(workflowModeChanged('edit')); + + dispatch( + addToast( + makeToast({ + title: t('workflows.newWorkflowCreated'), + status: 'success', + }) + ) + ); + + onClose(); + }, [dispatch, onClose, t]); + + const onClick = useCallback(() => { + if (!isTouched) { + handleNewWorkflow(); + return; + } + onOpen(); + }, [handleNewWorkflow, isTouched, onOpen]); + + return ( + <> + {props.renderButton(onClick)} + + + + {t('nodes.newWorkflowDesc')} + {t('nodes.newWorkflowDesc2')} + + + + ); +}); + +NewWorkflowConfirmationAlertDialog.displayName = 'NewWorkflowConfirmationAlertDialog'; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx index 33c3cee2bb..09a484f1e5 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx @@ -2,7 +2,7 @@ import { IconButton, useDisclosure } from '@invoke-ai/ui-library'; import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiBooksBold } from 'react-icons/pi'; +import { PiFolderOpenBold } from 'react-icons/pi'; import WorkflowLibraryModal from './WorkflowLibraryModal'; @@ -15,7 +15,7 @@ const WorkflowLibraryButton = () => { } + icon={} onClick={disclosure.onOpen} pointerEvents="auto" /> diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem.tsx index 8a41efc1b8..6c5baa584f 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem.tsx @@ -1,60 +1,22 @@ -import { ConfirmationAlertDialog, Flex, MenuItem, Text, useDisclosure } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; -import { addToast } from 'features/system/store/systemSlice'; -import { makeToast } from 'features/system/util/makeToast'; +import { MenuItem } from '@invoke-ai/ui-library'; +import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiFlowArrowBold } from 'react-icons/pi'; +import { PiFilePlusBold } from 'react-icons/pi'; -const NewWorkflowMenuItem = () => { +export const NewWorkflowMenuItem = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const isTouched = useAppSelector((s) => s.workflow.isTouched); - const handleNewWorkflow = useCallback(() => { - dispatch(nodeEditorReset()); - - dispatch( - addToast( - makeToast({ - title: t('workflows.newWorkflowCreated'), - status: 'success', - }) - ) - ); - - onClose(); - }, [dispatch, onClose, t]); - - const onClick = useCallback(() => { - if (!isTouched) { - handleNewWorkflow(); - return; - } - onOpen(); - }, [handleNewWorkflow, isTouched, onOpen]); - - return ( - <> - } onClick={onClick}> + const renderButton = useCallback( + (onClick: () => void) => ( + } onClick={onClick}> {t('nodes.newWorkflow')} - - - - {t('nodes.newWorkflowDesc')} - {t('nodes.newWorkflowDesc2')} - - - + ), + [t] ); -}; -export default memo(NewWorkflowMenuItem); + return ; +}); + +NewWorkflowMenuItem.displayName = 'NewWorkflowMenuItem'; 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 73d0249d3d..55d8ac2626 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx @@ -8,7 +8,7 @@ import { useGlobalMenuClose, } from '@invoke-ai/ui-library'; import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem'; -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 SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem'; import SettingsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SettingsMenuItem'; From 34cc26a4ed26665820292933bdbbec4e29820074 Mon Sep 17 00:00:00 2001 From: Mary Hipp Rogers Date: Wed, 14 Feb 2024 10:04:12 -0500 Subject: [PATCH 6/8] revert to using fetch, add token if needed (#5720) Co-authored-by: Mary Hipp --- .../web/src/common/hooks/useDownloadImage.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts index 3195426da3..26a17e1d0c 100644 --- a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts +++ b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts @@ -1,22 +1,28 @@ +import { useStore } from '@nanostores/react'; import { useAppToaster } from 'app/components/Toaster'; +import { $authToken } from 'app/store/nanostores/authToken'; import { useAppDispatch } from 'app/store/storeHooks'; import { imageDownloaded } from 'features/gallery/store/actions'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useImageUrlToBlob } from './useImageUrlToBlob'; - export const useDownloadImage = () => { const toaster = useAppToaster(); const { t } = useTranslation(); - const imageUrlToBlob = useImageUrlToBlob(); const dispatch = useAppDispatch(); + const authToken = useStore($authToken); const downloadImage = useCallback( async (image_url: string, image_name: string) => { try { - const blob = await imageUrlToBlob(image_url); - + const requestOpts = authToken + ? { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + : {}; + const blob = await fetch(image_url, requestOpts).then((resp) => resp.blob()); if (!blob) { throw new Error('Unable to create Blob'); } @@ -40,7 +46,7 @@ export const useDownloadImage = () => { }); } }, - [t, toaster, imageUrlToBlob, dispatch] + [t, toaster, dispatch, authToken] ); return { downloadImage }; From b77f6bd0ad5e8eb65e7854a063d6c2d540ae42ac Mon Sep 17 00:00:00 2001 From: Wubbbi Date: Wed, 14 Feb 2024 09:04:55 +0100 Subject: [PATCH 7/8] Update accelerate 0.26.1 -> 0.27.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a9a2157d8b..61bb9e7fa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] dependencies = [ # Core generation dependencies, pinned for reproducible builds. - "accelerate==0.26.1", + "accelerate==0.27.0", "clip_anytorch==2.5.2", # replacing "clip @ https://github.com/openai/CLIP/archive/eaa22acb90a5876642d0507623e859909230a52d.zip", "compel==2.0.2", "controlnet-aux==0.0.7", From 5ed2f6e6c19ff14eba0142a5e3eee2d8dd141458 Mon Sep 17 00:00:00 2001 From: Wubbbi Date: Wed, 14 Feb 2024 10:10:32 +0100 Subject: [PATCH 8/8] bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 61bb9e7fa2..7f4b0d77f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] dependencies = [ # Core generation dependencies, pinned for reproducible builds. - "accelerate==0.27.0", + "accelerate==0.27.2", "clip_anytorch==2.5.2", # replacing "clip @ https://github.com/openai/CLIP/archive/eaa22acb90a5876642d0507623e859909230a52d.zip", "compel==2.0.2", "controlnet-aux==0.0.7",