Skip to content

Commit f20ab50

Browse files
refactor(ui): workflow loading, saving and saved status tracking
This big chungus reworks and simplifies much of the logic around loading and saving workflows. It also makes some minor changes to how store the current workflow and determine if it is a draft, user workflow or default workflow. --- The lower-level hooks to save a workflow have been revised: - `useSaveLibraryWorkflow`: Saves a user or project workflow that has had changes made to it. - `useCreateNewWorkflow`: Saves a workflow as a new entity. A new higher-level hook `useSaveOrSaveAsWorkflow` is intended to be used by components. It returns a single function that: - Constructs the workflow payload to be sent to the server - Checks if the workflow is an existing user workflow. If so, it immediately saves (updates) that workflow. - If it's not an existing user workflow, it opens the save as dialog so the user can choose a name for it and create a new workflow. This occurs for both draft workflows and loaded default workflows. --- The logic to build the current redux state into a workflow - either to be saved as JSON, to update an existing user workflow, or save as - was a bit convoluted. Changes to redux state triggered a debounced function to build the workflow, setting it in a global nanostores atom. Then, all of the functions that consumed the "built workflow" referenced this atom. Now, this logic is strictly imperative. When a consumer wants to save a workflow, we build it on the spot. This removes a layer of indirection. The logic is in the `useBuildWorkflowFast` hook. --- The logic for loading a workflow is also revised. Previously, it happened in an RTK listener. You'd need to dispatch an action to load a workflow, and wouldn't know if it succeeded or not (though the listener would make a toast if the load failed). This is now done in a callback, outside redux middleware. The callback is returned from the `useLoadWorkflow` hook. --- Previously, we stripped the id from default workflows when loading them. Then, when saving the workflow, we built a workflow object from redux state and hit the API with it. This has two issues: - It relies on redux state never having an ID set when a default workflow is loaded. If we somehow ended up with a default workflow's ID in redux, when we go to save the workflow, we'd get and error or it wouldn't work, because you cannot save a default workflow. You can only save-as it. - We do not know the default workflow from which the current workflow was loaded. And be cause we don't know the default workflow, we cannot show a thumbnail image. The responsibilities have been shifted around a bit. Now, when we load a workflow, we load it as-is. The default workflow IDs are saved in redux state. We can render the thumbnail, and if the user goes to save the workflow, we detect that it is a default workflow and save-as it. --- In `App.tsx`, the long list of modals are moved into their own "isolator" component to ensure any re-renders there do not affect the rest of the app. --- The save-workflow-as modal is restructured to be a bit simpler. Still works the same. On commercial, "save to project" will be enabled by default. --- The workflow JSON tab uses a debounced version of "buildWorkflow" to build the workflow as JSON. --- `buildWorkflowFast` is updated to deep-copy its _whole_ output, preventing issues where field types could accidentally get mutated. I don't think this has ever happened but we may as well be safe. --- Fixed an issue where the edit button in the workflow list didn't open the workflow in edit mode.
1 parent 92098dd commit f20ab50

31 files changed

+549
-531
lines changed

invokeai/frontend/web/src/app/components/App.tsx

+35-22
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ import { selectLanguage } from 'features/system/store/systemSelectors';
3939
import { AppContent } from 'features/ui/components/AppContent';
4040
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
4141
import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
42+
import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
4243
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
44+
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
4345
import i18n from 'i18n';
4446
import { size } from 'lodash-es';
4547
import { memo, useCallback, useEffect } from 'react';
@@ -73,28 +75,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
7375
{!didStudioInit && <Loading />}
7476
</Box>
7577
<HookIsolator config={config} studioInitAction={studioInitAction} />
76-
<DeleteImageModal />
77-
<ChangeBoardModal />
78-
<DynamicPromptsModal />
79-
<StylePresetModal />
80-
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
81-
<ClearQueueConfirmationsAlertDialog />
82-
<NewWorkflowConfirmationAlertDialog />
83-
<LoadWorkflowConfirmationAlertDialog />
84-
<DeleteStylePresetDialog />
85-
<DeleteWorkflowDialog />
86-
<ShareWorkflowModal />
87-
<RefreshAfterResetModal />
88-
<DeleteBoardModal />
89-
<GlobalImageHotkeys />
90-
<NewGallerySessionDialog />
91-
<NewCanvasSessionDialog />
92-
<ImageContextMenu />
93-
<FullscreenDropzone />
94-
<VideosModal />
95-
<CanvasManagerProviderGate>
96-
<CanvasPasteModal />
97-
</CanvasManagerProviderGate>
78+
<ModalIsolator />
9879
</ErrorBoundary>
9980
);
10081
};
@@ -140,3 +121,35 @@ const HookIsolator = memo(
140121
}
141122
);
142123
HookIsolator.displayName = 'HookIsolator';
124+
125+
const ModalIsolator = memo(() => {
126+
return (
127+
<>
128+
<DeleteImageModal />
129+
<ChangeBoardModal />
130+
<DynamicPromptsModal />
131+
<StylePresetModal />
132+
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
133+
<ClearQueueConfirmationsAlertDialog />
134+
<NewWorkflowConfirmationAlertDialog />
135+
<LoadWorkflowConfirmationAlertDialog />
136+
<DeleteStylePresetDialog />
137+
<DeleteWorkflowDialog />
138+
<ShareWorkflowModal />
139+
<RefreshAfterResetModal />
140+
<DeleteBoardModal />
141+
<GlobalImageHotkeys />
142+
<NewGallerySessionDialog />
143+
<NewCanvasSessionDialog />
144+
<ImageContextMenu />
145+
<FullscreenDropzone />
146+
<VideosModal />
147+
<SaveWorkflowAsDialog />
148+
<CanvasManagerProviderGate>
149+
<CanvasPasteModal />
150+
</CanvasManagerProviderGate>
151+
<LoadWorkflowFromGraphModal />
152+
</>
153+
);
154+
});
155+
ModalIsolator.displayName = 'ModalIsolator';

invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts

-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import { addDynamicPromptsListener } from 'app/store/middleware/listenerMiddlewa
2727
import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings';
2828
import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
2929
import { addUpdateAllNodesRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested';
30-
import { addWorkflowLoadRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested';
3130
import type { AppDispatch, RootState } from 'app/store/store';
3231

3332
import { addArchivedOrDeletedBoardListener } from './listeners/addArchivedOrDeletedBoardListener';
@@ -89,7 +88,6 @@ addArchivedOrDeletedBoardListener(startAppListening);
8988
addGetOpenAPISchemaListener(startAppListening);
9089

9190
// Workflows
92-
addWorkflowLoadRequestedListener(startAppListening);
9391
addUpdateAllNodesRequestedListener(startAppListening);
9492

9593
// Models

invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx

-4
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { useFocusRegion } from 'common/hooks/focus';
44
import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk';
55
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
66
import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
7-
import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
8-
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
97
import { memo, useRef } from 'react';
108
import { useTranslation } from 'react-i18next';
119
import { PiFlowArrowBold } from 'react-icons/pi';
@@ -40,8 +38,6 @@ const NodeEditor = () => {
4038
<TopPanel />
4139
<BottomLeftPanel />
4240
<MinimapPanel />
43-
<SaveWorkflowAsDialog />
44-
<LoadWorkflowFromGraphModal />
4541
</>
4642
)}
4743
<WorkflowEditorSettings />

invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { useConnection } from 'features/nodes/hooks/useConnection';
2020
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
2121
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
2222
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
23-
import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher';
2423
import {
2524
$addNodeCmdk,
2625
$cursorPos,
@@ -95,7 +94,6 @@ export const Flow = memo(() => {
9594
const isWorkflowsFocused = useIsRegionFocused('workflows');
9695
useFocusRegion('workflows', flowWrapper);
9796

98-
useWorkflowWatcher();
9997
useSyncExecutionState();
10098
const [borderRadius] = useToken('radii', ['base']);
10199
const flowStyles = useMemo<CSSProperties>(() => ({ borderRadius }), [borderRadius]);

invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx

+4-20
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,23 @@
11
import { IconButton } from '@invoke-ai/ui-library';
22
import { useAppSelector } from 'app/store/storeHooks';
3-
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
43
import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
5-
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
6-
import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
7-
import { memo, useCallback } from 'react';
4+
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
5+
import { memo } from 'react';
86
import { useTranslation } from 'react-i18next';
97
import { PiFloppyDiskBold } from 'react-icons/pi';
108

119
const SaveWorkflowButton = () => {
1210
const { t } = useTranslation();
1311
const isTouched = useAppSelector(selectWorkflowIsTouched);
14-
const { onOpen } = useSaveWorkflowAsDialog();
15-
const { saveWorkflow } = useSaveLibraryWorkflow();
16-
17-
const handleClickSave = useCallback(() => {
18-
const builtWorkflow = $builtWorkflow.get();
19-
if (!builtWorkflow) {
20-
return;
21-
}
22-
23-
if (isWorkflowWithID(builtWorkflow)) {
24-
saveWorkflow();
25-
} else {
26-
onOpen();
27-
}
28-
}, [onOpen, saveWorkflow]);
12+
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
2913

3014
return (
3115
<IconButton
3216
tooltip={t('workflows.saveWorkflow')}
3317
aria-label={t('workflows.saveWorkflow')}
3418
icon={<PiFloppyDiskBold />}
3519
isDisabled={!isTouched}
36-
onClick={handleClickSave}
20+
onClick={saveOrSaveAsWorkflow}
3721
pointerEvents="auto"
3822
/>
3923
);

invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx

+4-26
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,19 @@
11
import { IconButton } from '@invoke-ai/ui-library';
2-
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
3-
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
4-
import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
5-
import type { MouseEventHandler } from 'react';
6-
import { memo, useCallback } from 'react';
2+
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
3+
import { memo } from 'react';
74
import { useTranslation } from 'react-i18next';
85
import { PiFloppyDiskBold } from 'react-icons/pi';
96

107
const SaveWorkflowButton = () => {
118
const { t } = useTranslation();
12-
const { onOpen } = useSaveWorkflowAsDialog();
13-
const { saveWorkflow } = useSaveLibraryWorkflow();
14-
15-
const handleClickSave = useCallback<MouseEventHandler<HTMLButtonElement>>(
16-
(e) => {
17-
e.stopPropagation();
18-
19-
const builtWorkflow = $builtWorkflow.get();
20-
if (!builtWorkflow) {
21-
return;
22-
}
23-
24-
if (isWorkflowWithID(builtWorkflow)) {
25-
saveWorkflow();
26-
} else {
27-
onOpen();
28-
}
29-
},
30-
[onOpen, saveWorkflow]
31-
);
9+
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
3210

3311
return (
3412
<IconButton
3513
tooltip={t('workflows.saveWorkflow')}
3614
aria-label={t('workflows.saveWorkflow')}
3715
icon={<PiFloppyDiskBold />}
38-
onClick={handleClickSave}
16+
onClick={saveOrSaveAsWorkflow}
3917
pointerEvents="auto"
4018
variant="ghost"
4119
size="sm"

invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx

+16-8
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,23 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
3939
return workflowId === workflow.workflow_id;
4040
}, [workflowId, workflow.workflow_id]);
4141

42-
const handleClickLoad = useCallback(() => {
43-
setIsHovered(false);
44-
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
45-
}, [loadWorkflow, workflow.workflow_id]);
42+
const handleClickLoad = useCallback(
43+
(e: MouseEvent<HTMLDivElement>) => {
44+
e.stopPropagation();
45+
setIsHovered(false);
46+
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
47+
},
48+
[loadWorkflow, workflow.workflow_id]
49+
);
4650

47-
const handleClickEdit = useCallback(() => {
48-
setIsHovered(false);
49-
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
50-
}, [loadWorkflow, workflow.workflow_id]);
51+
const handleClickEdit = useCallback(
52+
(e: MouseEvent<HTMLButtonElement>) => {
53+
e.stopPropagation();
54+
setIsHovered(false);
55+
loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit');
56+
},
57+
[loadWorkflow, workflow.workflow_id]
58+
);
5159

5260
const handleClickDelete = useCallback(
5361
(e: MouseEvent<HTMLButtonElement>) => {

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx

+44-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FormControlProps } from '@invoke-ai/ui-library';
2-
import { Flex, FormControl, FormControlGroup, FormLabel, Input, Textarea } from '@invoke-ai/ui-library';
2+
import { Box, Flex, FormControl, FormControlGroup, FormLabel, Image, Input, Textarea } from '@invoke-ai/ui-library';
33
import { skipToken } from '@reduxjs/toolkit/query';
44
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
55
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@@ -40,8 +40,6 @@ const WorkflowGeneralTab = () => {
4040
const { id, author, name, description, tags, version, contact, notes } = useAppSelector(selector);
4141
const dispatch = useAppDispatch();
4242

43-
const { data } = useGetWorkflowQuery(id ?? skipToken);
44-
4543
const handleChangeName = useCallback(
4644
(e: ChangeEvent<HTMLInputElement>) => {
4745
dispatch(workflowNameChanged(e.target.value));
@@ -96,17 +94,7 @@ const WorkflowGeneralTab = () => {
9694
<FormLabel>{t('nodes.workflowName')}</FormLabel>
9795
<Input variant="darkFilled" value={name} onChange={handleChangeName} />
9896
</FormControl>
99-
{/*
100-
* Only saved and non-default workflows can have a thumbnail.
101-
* - Unsaved workflows have no id.
102-
* - Default workflows have a category of 'default'.
103-
*/}
104-
{id && data && data.workflow.meta.category !== 'default' && (
105-
<FormControl>
106-
<FormLabel>{t('workflows.workflowThumbnail')}</FormLabel>
107-
<WorkflowThumbnailEditor thumbnailUrl={data.thumbnail_url} workflowId={id} />
108-
</FormControl>
109-
)}
97+
<Thumbnail id={id} />
11098
<FormControl>
11199
<FormLabel>{t('nodes.workflowVersion')}</FormLabel>
112100
<Input variant="darkFilled" value={version} onChange={handleChangeVersion} />
@@ -156,3 +144,45 @@ export default memo(WorkflowGeneralTab);
156144
const formControlProps: FormControlProps = {
157145
flexShrink: 0,
158146
};
147+
148+
const Thumbnail = ({ id }: { id?: string | null }) => {
149+
const { t } = useTranslation();
150+
151+
const { data } = useGetWorkflowQuery(id ?? skipToken);
152+
153+
if (!data) {
154+
return null;
155+
}
156+
157+
if (data.workflow.meta.category === 'default' && data.thumbnail_url) {
158+
// This is a default workflow and it has a thumbnail set. Users may only view the thumbnail.
159+
return (
160+
<FormControl>
161+
<FormLabel>{t('workflows.workflowThumbnail')}</FormLabel>
162+
<Box position="relative" flexShrink={0}>
163+
<Image
164+
src={data.thumbnail_url}
165+
objectFit="cover"
166+
objectPosition="50% 50%"
167+
w={100}
168+
h={100}
169+
borderRadius="base"
170+
/>
171+
</Box>
172+
</FormControl>
173+
);
174+
}
175+
176+
if (data.workflow.meta.category !== 'default') {
177+
// This is a user workflow and they may edit the thumbnail.
178+
return (
179+
<FormControl>
180+
<FormLabel>{t('workflows.workflowThumbnail')}</FormLabel>
181+
<WorkflowThumbnailEditor thumbnailUrl={data.thumbnail_url} workflowId={data.workflow_id} />
182+
</FormControl>
183+
);
184+
}
185+
186+
// This is a default workflow and it does not have a thumbnail set. Users may not edit the thumbnail.
187+
return null;
188+
};

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx

+38-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,51 @@
11
import { Flex } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
3+
import { EMPTY_OBJECT } from 'app/store/constants';
4+
import { useAppSelector } from 'app/store/storeHooks';
35
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
4-
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
5-
import { memo } from 'react';
6+
import { selectNodesSlice } from 'features/nodes/store/selectors';
7+
import type { NodesState, WorkflowsState } from 'features/nodes/store/types';
8+
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
9+
import type { WorkflowV3 } from 'features/nodes/types/workflow';
10+
import { buildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
11+
import { debounce } from 'lodash-es';
12+
import { atom, computed } from 'nanostores';
13+
import { memo, useEffect } from 'react';
614
import { useTranslation } from 'react-i18next';
715

16+
const $maybePreviewWorkflow = atom<WorkflowV3 | null>(null);
17+
const $previewWorkflow = computed(
18+
$maybePreviewWorkflow,
19+
(maybePreviewWorkflow) => maybePreviewWorkflow ?? EMPTY_OBJECT
20+
);
21+
22+
const debouncedBuildPreviewWorkflow = debounce(
23+
(nodes: NodesState['nodes'], edges: NodesState['edges'], workflow: WorkflowsState) => {
24+
$maybePreviewWorkflow.set(buildWorkflowFast({ nodes, edges, workflow }));
25+
},
26+
300
27+
);
28+
29+
const IsolatedWorkflowBuilderWatcher = memo(() => {
30+
const { nodes, edges } = useAppSelector(selectNodesSlice);
31+
const workflow = useAppSelector(selectWorkflowSlice);
32+
33+
useEffect(() => {
34+
debouncedBuildPreviewWorkflow(nodes, edges, workflow);
35+
}, [edges, nodes, workflow]);
36+
37+
return null;
38+
});
39+
IsolatedWorkflowBuilderWatcher.displayName = 'IsolatedWorkflowBuilderWatcher';
40+
841
const WorkflowJSONTab = () => {
9-
const workflow = useStore($builtWorkflow);
42+
const previewWorkflow = useStore($previewWorkflow);
1043
const { t } = useTranslation();
1144

1245
return (
1346
<Flex flexDir="column" alignItems="flex-start" gap={2} h="full">
14-
<DataViewer data={workflow ?? {}} label={t('nodes.workflow')} bg="base.850" color="base.200" />
47+
<DataViewer data={previewWorkflow} label={t('nodes.workflow')} bg="base.850" color="base.200" />
48+
<IsolatedWorkflowBuilderWatcher />
1549
</Flex>
1650
);
1751
};

0 commit comments

Comments
 (0)