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.
This commit is contained in:
psychedelicious
2025-03-04 14:08:39 +10:00
parent 3c2e6378ca
commit 07d65b8fd1
31 changed files with 549 additions and 531 deletions

View File

@@ -14,9 +14,8 @@ import {
Textarea,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow';
import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow';
import { atom } from 'nanostores';
import type { ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
@@ -38,7 +37,7 @@ export const useLoadWorkflowFromGraphModal = () => {
export const LoadWorkflowFromGraphModal = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const _loadWorkflow = useLoadWorkflow();
const { isOpen, onClose } = useLoadWorkflowFromGraphModal();
const [graphRaw, setGraphRaw] = useState<string>('');
const [workflowRaw, setWorkflowRaw] = useState<string>('');
@@ -58,9 +57,9 @@ export const LoadWorkflowFromGraphModal = () => {
setWorkflowRaw(JSON.stringify(workflow, null, 2));
}, [graphRaw, shouldAutoLayout]);
const loadWorkflow = useCallback(() => {
dispatch(workflowLoadRequested({ data: { workflow: workflowRaw, graph: null }, asCopy: true }));
_loadWorkflow({ workflow: workflowRaw, graph: null });
onClose();
}, [dispatch, onClose, workflowRaw]);
}, [_loadWorkflow, onClose, workflowRaw]);
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered useInert={false}>
<ModalOverlay />

View File

@@ -0,0 +1,169 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
Button,
Checkbox,
Flex,
FormControl,
FormLabel,
Input,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { deepClone } from 'common/util/deepClone';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { isDraftWorkflow, useCreateLibraryWorkflow } from 'features/workflowLibrary/hooks/useCreateNewWorkflow';
import { t } from 'i18next';
import { atom, computed } from 'nanostores';
import type { ChangeEvent, RefObject } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { assert } from 'tsafe';
/**
* The workflow to save as a new workflow.
*
* This state is used to determine whether or not the modal is open.
*/
const $workflowToSave = atom<WorkflowV3 | null>(null);
/**
* Whether or not the modal is open. It is open if there is a workflow to save.
*
* The state is derived from the workflow to save.
*
* To open the modal, set the workflow to save to a workflow object.
* To close the modal, set the workflow to save to null.
*/
const $isOpen = computed($workflowToSave, (val) => val !== null);
const getInitialName = (workflow: WorkflowV3): string => {
if (!workflow.id) {
// If the workflow has no ID, that means it's a new workflow that has never been saved to the server. In this case,
// we should use whatever the user has entered in the workflow name field.
return workflow.name;
}
// Otherwise, the workflow is already saved to the server.
if (workflow.name.length) {
// This workflow has a name so let's use the workflow's name with " (copy)" appended to it.
return `${workflow.name.trim()} (copy)`;
}
// Fallback - will show a placeholder in the input field.
return '';
};
/**
* Save the workflow as a new workflow. This will open a dialog where the user can enter the name of the new workflow.
* The workflow object is deep cloned to prevent any changes to the original workflow object.
* @param workflow The workflow to save as a new workflow.
*/
export const saveWorkflowAs = (workflow: WorkflowV3) => {
$workflowToSave.set(deepClone(workflow));
};
export const SaveWorkflowAsDialog = () => {
const isOpen = useStore($isOpen);
const workflowToSave = useStore($workflowToSave);
const cancelRef = useRef<HTMLButtonElement>(null);
const onClose = useCallback(() => {
$workflowToSave.set(null);
}, []);
return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered={true}>
{!workflowToSave && <NoWorkflowToSaveContent />}
{workflowToSave && <Content workflow={workflowToSave} cancelRef={cancelRef} />}
</AlertDialog>
);
};
const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef: RefObject<HTMLButtonElement> }) => {
const workflowCategories = useStore($workflowCategories);
const [name, setName] = useState(() => {
if (workflow) {
return getInitialName(workflow);
}
return '';
});
const [shouldSaveToProject, setShouldSaveToProject] = useState(() => workflowCategories.includes('project'));
const { createNewWorkflow } = useCreateLibraryWorkflow();
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
}, []);
const onChangeCheckbox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setShouldSaveToProject(e.target.checked);
},
[setShouldSaveToProject]
);
const onClose = useCallback(() => {
$workflowToSave.set(null);
}, []);
const onSave = useCallback(async () => {
workflow.id = undefined;
workflow.name = name;
workflow.meta.category = shouldSaveToProject ? 'project' : 'user';
// We've just made the workflow a draft, but TS doesn't know that. We need to assert it.
assert(isDraftWorkflow(workflow));
await createNewWorkflow({
workflow,
onSuccess: onClose,
onError: onClose,
});
}, [workflow, name, shouldSaveToProject, createNewWorkflow, onClose]);
return (
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('workflows.saveWorkflowAs')}
</AlertDialogHeader>
<AlertDialogBody>
<FormControl alignItems="flex-start">
<FormLabel mt="2">{t('workflows.workflowName')}</FormLabel>
<Flex flexDir="column" width="full" gap="2">
<Input ref={inputRef} value={name} onChange={onChange} placeholder={t('workflows.workflowName')} />
{workflowCategories.includes('project') && (
<Checkbox isChecked={shouldSaveToProject} onChange={onChangeCheckbox}>
<FormLabel>{t('workflows.saveWorkflowToProject')}</FormLabel>
</Checkbox>
)}
</Flex>
</FormControl>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="invokeBlue" onClick={onSave} ml={3} isDisabled={!name || !name.length}>
{t('common.saveAs')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
);
});
Content.displayName = 'Content';
const NoWorkflowToSaveContent = memo(() => {
return (
<AlertDialogContent>
<IAINoContentFallback icon={null} label="No workflow to save" />
</AlertDialogContent>
);
});
NoWorkflowToSaveContent.displayName = 'NoWorkflowToSaveContent';

View File

@@ -1,101 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
Checkbox,
Flex,
FormControl,
FormLabel,
Input,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { useSaveWorkflowAs } from 'features/workflowLibrary/hooks/useSaveWorkflowAs';
import { t } from 'i18next';
import type { ChangeEvent } from 'react';
import { useCallback, useRef } from 'react';
export const SaveWorkflowAsDialog = () => {
const { isOpen, onClose, workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject } =
useSaveWorkflowAsDialog();
const workflowCategories = useStore($workflowCategories);
const { saveWorkflowAs } = useSaveWorkflowAs();
const cancelRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setWorkflowName(e.target.value);
},
[setWorkflowName]
);
const onChangeCheckbox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setShouldSaveToProject(e.target.checked);
},
[setShouldSaveToProject]
);
const clearAndClose = useCallback(() => {
onClose();
}, [onClose]);
const onSave = useCallback(async () => {
const category = shouldSaveToProject ? 'project' : 'user';
await saveWorkflowAs({
name: workflowName,
category,
onSuccess: clearAndClose,
onError: clearAndClose,
});
}, [workflowName, saveWorkflowAs, shouldSaveToProject, clearAndClose]);
return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered={true}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('workflows.saveWorkflowAs')}
</AlertDialogHeader>
<AlertDialogBody>
<FormControl alignItems="flex-start">
<FormLabel mt="2">{t('workflows.workflowName')}</FormLabel>
<Flex flexDir="column" width="full" gap="2">
<Input
ref={inputRef}
value={workflowName}
onChange={onChange}
placeholder={t('workflows.workflowName')}
/>
{workflowCategories.includes('project') && (
<Checkbox isChecked={shouldSaveToProject} onChange={onChangeCheckbox}>
<FormLabel>{t('workflows.saveWorkflowToProject')}</FormLabel>
</Checkbox>
)}
</Flex>
</FormControl>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={clearAndClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="invokeBlue" onClick={onSave} ml={3} isDisabled={!workflowName || !workflowName.length}>
{t('common.saveAs')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@@ -1,52 +0,0 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName';
import { atom } from 'nanostores';
import { useCallback } from 'react';
const $isOpen = atom(false);
const $workflowName = atom('');
const $shouldSaveToProject = atom(false);
const selectNewWorkflowName = createSelector(selectWorkflowSlice, ({ name, id }): string => {
// If the workflow has no ID, it's a new workflow that has never been saved to the server. The dialog should use
// whatever the user has entered in the workflow name field.
if (!id) {
return name;
}
// Else, the workflow is already saved to the server. The dialog should use the workflow's name with " (copy)"
// appended to it.
if (name.length) {
return getWorkflowCopyName(name);
}
// Else, we have a workflow that has been saved to the server, but has no name. This should never happen, but if
// it does, we just return an empty string and let the dialog use the default name.
return '';
});
export const useSaveWorkflowAsDialog = () => {
const newWorkflowName = useAppSelector(selectNewWorkflowName);
const isOpen = useStore($isOpen);
const onOpen = useCallback(() => {
$workflowName.set(newWorkflowName);
$isOpen.set(true);
}, [newWorkflowName]);
const onClose = useCallback(() => {
$isOpen.set(false);
$workflowName.set('');
$shouldSaveToProject.set(false);
}, []);
const workflowName = useStore($workflowName);
const setWorkflowName = useCallback((workflowName: string) => $workflowName.set(workflowName), []);
const shouldSaveToProject = useStore($shouldSaveToProject);
const setShouldSaveToProject = useCallback((shouldSaveToProject: boolean) => {
$shouldSaveToProject.set(shouldSaveToProject);
}, []);
return { workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject, isOpen, onOpen, onClose };
};

View File

@@ -1,24 +1,22 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu';
import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
import { memo, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { PiUploadSimpleBold } from 'react-icons/pi';
import { useSaveWorkflowAsDialog } from './SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
const UploadWorkflowButton = () => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const workflowListMenu = useWorkflowListMenu();
const saveWorkflowAsDialog = useSaveWorkflowAsDialog();
const loadWorkflowFromFile = useLoadWorkflowFromFile({
resetRef,
onSuccess: () => {
onSuccess: (workflow) => {
workflowListMenu.close();
saveWorkflowAsDialog.onOpen();
saveWorkflowAs(workflow);
},
});

View File

@@ -1,15 +1,26 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { memo } from 'react';
import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import type { MouseEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold } from 'react-icons/pi';
const SaveWorkflowAsMenuItem = () => {
const { t } = useTranslation();
const { onOpen } = useSaveWorkflowAsDialog();
const buildWorkflow = useBuildWorkflowFast();
const handleClickSave = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
const workflow = buildWorkflow();
saveWorkflowAs(workflow);
},
[buildWorkflow]
);
return (
<MenuItem as="button" icon={<PiCopyBold />} onClick={onOpen}>
<MenuItem as="button" icon={<PiCopyBold />} onClick={handleClickSave}>
{t('workflows.saveWorkflowAs')}
</MenuItem>
);

View File

@@ -1,34 +1,18 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
import { memo, useCallback } from 'react';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowMenuItem = () => {
const { t } = useTranslation();
const { saveWorkflow } = useSaveLibraryWorkflow();
const { onOpen } = useSaveWorkflowAsDialog();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
const isTouched = useAppSelector(selectWorkflowIsTouched);
const handleClickSave = useCallback(() => {
const builtWorkflow = $builtWorkflow.get();
if (!builtWorkflow) {
return;
}
if (isWorkflowWithID(builtWorkflow)) {
saveWorkflow();
} else {
onOpen();
}
}, [onOpen, saveWorkflow]);
return (
<MenuItem as="button" isDisabled={!isTouched} icon={<PiFloppyDiskBold />} onClick={handleClickSave}>
<MenuItem as="button" isDisabled={!isTouched} icon={<PiFloppyDiskBold />} onClick={saveOrSaveAsWorkflow}>
{t('workflows.saveWorkflow')}
</MenuItem>
);

View File

@@ -1,7 +1,6 @@
import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import {
formFieldInitialValuesChanged,
workflowCategoryChanged,
@@ -9,42 +8,48 @@ import {
workflowNameChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues';
import { newWorkflowSaved } from 'features/workflowLibrary/store/actions';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { SetFieldType } from 'type-fest';
type SaveWorkflowAsArg = {
name: string;
category: WorkflowCategory;
/**
* A draft workflow is a workflow that is has not been saved yet. It does not have an id and is not in the default category.
*/
type DraftWorkflow = SetFieldType<
SetFieldType<WorkflowV3, 'id', undefined>,
'meta',
SetFieldType<WorkflowV3['meta'], 'category', Exclude<WorkflowV3['meta']['category'], 'default'>>
>;
export const isDraftWorkflow = (workflow: WorkflowV3): workflow is DraftWorkflow =>
!workflow.id && workflow.meta.category !== 'default';
type CreateLibraryWorkflowArg = {
workflow: DraftWorkflow;
onSuccess?: () => void;
onError?: () => void;
};
type UseSaveWorkflowAsReturn = {
saveWorkflowAs: (arg: SaveWorkflowAsArg) => Promise<void>;
type CreateLibraryWorkflowReturn = {
createNewWorkflow: (arg: CreateLibraryWorkflowArg) => Promise<void>;
isLoading: boolean;
isError: boolean;
};
type UseSaveWorkflowAs = () => UseSaveWorkflowAsReturn;
export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation();
const [createWorkflow, { isLoading, isError }] = useCreateWorkflowMutation();
const getFormFieldInitialValues = useGetFormFieldInitialValues();
const toast = useToast();
const toastRef = useRef<ToastId | undefined>();
const saveWorkflowAs = useCallback(
async ({ name: newName, category, onSuccess, onError }: SaveWorkflowAsArg) => {
const workflow = $builtWorkflow.get();
if (!workflow) {
return;
}
const createNewWorkflow = useCallback(
async ({ workflow, onSuccess, onError }: CreateLibraryWorkflowArg) => {
toastRef.current = toast({
title: t('workflows.savingWorkflow'),
status: 'loading',
@@ -52,18 +57,19 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
isClosable: false,
});
try {
workflow.id = undefined;
workflow.name = newName;
workflow.meta.category = category;
const data = await createWorkflow(workflow).unwrap();
dispatch(workflowIDChanged(data.workflow.id));
dispatch(workflowNameChanged(data.workflow.name));
dispatch(workflowCategoryChanged(data.workflow.meta.category));
const {
id,
name,
meta: { category },
} = data.workflow;
dispatch(workflowIDChanged(id));
dispatch(workflowNameChanged(name));
dispatch(workflowCategoryChanged(category));
dispatch(workflowSaved());
dispatch(newWorkflowSaved({ category }));
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
dispatch(newWorkflowSaved({ category }));
onSuccess && onSuccess();
toast.update(toastRef.current, {
@@ -89,8 +95,8 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
[toast, t, createWorkflow, dispatch, getFormFieldInitialValues]
);
return {
saveWorkflowAs,
isLoading: createWorkflowResult.isLoading,
isError: createWorkflowResult.isError,
createNewWorkflow,
isLoading,
isError,
};
};

View File

@@ -1,16 +1,15 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
import { workflowDownloaded } from 'features/workflowLibrary/store/actions';
import { useCallback } from 'react';
export const useDownloadCurrentlyLoadedWorkflow = () => {
const dispatch = useAppDispatch();
const buildWorkflow = useBuildWorkflowFast();
const downloadWorkflow = useCallback(() => {
const workflow = $builtWorkflow.get();
if (!workflow) {
return;
}
const workflow = buildWorkflow();
const blob = new Blob([JSON.stringify(workflow, null, 2)]);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
@@ -19,7 +18,7 @@ export const useDownloadCurrentlyLoadedWorkflow = () => {
a.click();
a.remove();
dispatch(workflowDownloaded());
}, [dispatch]);
}, [buildWorkflow, dispatch]);
return downloadWorkflow;
};

View File

@@ -1,6 +1,5 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { toast } from 'features/toast/toast';
import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLazyGetImageWorkflowQuery } from 'services/api/endpoints/images';
@@ -11,15 +10,15 @@ type UseGetAndLoadEmbeddedWorkflowOptions = {
};
export const useGetAndLoadEmbeddedWorkflow = (options?: UseGetAndLoadEmbeddedWorkflowOptions) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [_getAndLoadEmbeddedWorkflow, result] = useLazyGetImageWorkflowQuery();
const loadWorkflow = useLoadWorkflow();
const getAndLoadEmbeddedWorkflow = useCallback(
async (imageName: string) => {
try {
const { data } = await _getAndLoadEmbeddedWorkflow(imageName);
if (data) {
dispatch(workflowLoadRequested({ data, asCopy: true }));
loadWorkflow(data);
// No toast - the listener for this action does that after the workflow is loaded
options?.onSuccess && options?.onSuccess();
} else {
@@ -38,7 +37,7 @@ export const useGetAndLoadEmbeddedWorkflow = (options?: UseGetAndLoadEmbeddedWor
options?.onError && options?.onError();
}
},
[_getAndLoadEmbeddedWorkflow, dispatch, options, t]
[_getAndLoadEmbeddedWorkflow, loadWorkflow, options, t]
);
return [getAndLoadEmbeddedWorkflow, result] as const;

View File

@@ -1,6 +1,5 @@
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLazyGetWorkflowQuery, workflowsApi } from 'services/api/endpoints/workflows';
@@ -18,16 +17,16 @@ type UseGetAndLoadLibraryWorkflowReturn = {
type UseGetAndLoadLibraryWorkflow = (arg?: UseGetAndLoadLibraryWorkflowOptions) => UseGetAndLoadLibraryWorkflowReturn;
export const useGetAndLoadLibraryWorkflow: UseGetAndLoadLibraryWorkflow = (arg) => {
const dispatch = useAppDispatch();
const toast = useToast();
const { t } = useTranslation();
const loadWorkflow = useLoadWorkflow();
const [_getAndLoadWorkflow, getAndLoadWorkflowResult] = useLazyGetWorkflowQuery();
const getAndLoadWorkflow = useCallback(
async (workflow_id: string) => {
try {
const { workflow } = await _getAndLoadWorkflow(workflow_id).unwrap();
// This action expects a stringified workflow, instead of updating the routes and services we will just stringify it here
dispatch(workflowLoadRequested({ data: { workflow: JSON.stringify(workflow), graph: null }, asCopy: false }));
loadWorkflow({ workflow: JSON.stringify(workflow), graph: null });
// No toast - the listener for this action does that after the workflow is loaded
arg?.onSuccess && arg.onSuccess();
} catch {
@@ -39,7 +38,7 @@ export const useGetAndLoadLibraryWorkflow: UseGetAndLoadLibraryWorkflow = (arg)
arg?.onError && arg.onError();
}
},
[_getAndLoadWorkflow, dispatch, arg, t, toast]
[_getAndLoadWorkflow, loadWorkflow, arg, toast, t]
);
return { getAndLoadWorkflow, getAndLoadWorkflowResult };

View File

@@ -0,0 +1,123 @@
import { logger } from 'app/logging/logger';
import { useAppDispatch } from 'app/store/storeHooks';
import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState';
import { workflowLoaded } from 'features/nodes/store/actions';
import { $templates } from 'features/nodes/store/nodesSlice';
import { $needsFit } from 'features/nodes/store/reactFlowInstance';
import type { Templates } from 'features/nodes/store/types';
import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
import { checkBoardAccess, checkImageAccess, checkModelAccess } from 'services/api/hooks/accessChecks';
import type { GraphAndWorkflowResponse, NonNullableGraph } from 'services/api/types';
import { z } from 'zod';
import { fromZodError } from 'zod-validation-error';
const log = logger('workflows');
const getWorkflowFromStringifiedWorkflowOrGraph = async (data: GraphAndWorkflowResponse, templates: Templates) => {
if (data.workflow) {
// Prefer to load the workflow if it's available - it has more information
const parsed = JSON.parse(data.workflow);
return await validateWorkflow({
workflow: parsed,
templates,
checkImageAccess,
checkBoardAccess,
checkModelAccess,
});
} else if (data.graph) {
// Else we fall back on the graph, using the graphToWorkflow function to convert and do layout
const parsed = JSON.parse(data.graph);
const workflow = graphToWorkflow(parsed as NonNullableGraph, true);
return await validateWorkflow({ workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess });
} else {
throw new Error('No workflow or graph provided');
}
};
export const useLoadWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const loadWorkflow = useCallback(
async (data: GraphAndWorkflowResponse): Promise<WorkflowV3 | null> => {
try {
const templates = $templates.get();
const { workflow, warnings } = await getWorkflowFromStringifiedWorkflowOrGraph(data, templates);
$nodeExecutionStates.set({});
dispatch(workflowLoaded(workflow));
if (!warnings.length) {
toast({
id: 'WORKFLOW_LOADED',
title: t('toast.workflowLoaded'),
status: 'success',
});
} else {
toast({
id: 'WORKFLOW_LOADED',
title: t('toast.loadedWithWarnings'),
status: 'warning',
});
warnings.forEach(({ message, ...rest }) => {
log.warn(rest, message);
});
}
$needsFit.set(true);
return workflow;
} catch (e) {
if (e instanceof WorkflowVersionError) {
// The workflow version was not recognized in the valid list of versions
log.error({ error: serializeError(e) }, e.message);
toast({
id: 'UNABLE_TO_VALIDATE_WORKFLOW',
title: t('nodes.unableToValidateWorkflow'),
status: 'error',
description: e.message,
});
} else if (e instanceof WorkflowMigrationError) {
// There was a problem migrating the workflow to the latest version
log.error({ error: serializeError(e) }, e.message);
toast({
id: 'UNABLE_TO_VALIDATE_WORKFLOW',
title: t('nodes.unableToValidateWorkflow'),
status: 'error',
description: e.message,
});
} else if (e instanceof z.ZodError) {
// There was a problem validating the workflow itself
const { message } = fromZodError(e, {
prefix: t('nodes.workflowValidation'),
});
log.error({ error: serializeError(e) }, message);
toast({
id: 'UNABLE_TO_VALIDATE_WORKFLOW',
title: t('nodes.unableToValidateWorkflow'),
status: 'error',
description: message,
});
} else {
// Some other error occurred
log.error({ error: serializeError(e) }, t('nodes.unknownErrorValidatingWorkflow'));
toast({
id: 'UNABLE_TO_VALIDATE_WORKFLOW',
title: t('nodes.unableToValidateWorkflow'),
status: 'error',
description: t('nodes.unknownErrorValidatingWorkflow'),
});
}
return null;
}
},
[dispatch, t]
);
return loadWorkflow;
};

View File

@@ -1,44 +1,36 @@
import { useLogger } from 'app/logging/useLogger';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { toast } from 'features/toast/toast';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow';
import { workflowLoadedFromFile } from 'features/workflowLibrary/store/actions';
import type { RefObject } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { assert } from 'tsafe';
type useLoadWorkflowFromFileOptions = {
resetRef: RefObject<() => void>;
onSuccess?: () => void;
onSuccess?: (workflow: WorkflowV3) => void;
};
type UseLoadWorkflowFromFile = (options: useLoadWorkflowFromFileOptions) => (file: File | null) => void;
export const useLoadWorkflowFromFile: UseLoadWorkflowFromFile = ({ resetRef, onSuccess }) => {
const dispatch = useAppDispatch();
const logger = useLogger('workflows');
const { t } = useTranslation();
const loadWorkflow = useLoadWorkflow();
const loadWorkflowFromFile = useCallback(
(file: File | null) => {
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
reader.onload = async () => {
const rawJSON = reader.result;
try {
dispatch(workflowLoadRequested({ data: { workflow: String(rawJSON), graph: null }, asCopy: true }));
const workflow = await loadWorkflow({ workflow: String(rawJSON), graph: null });
assert(workflow !== null);
dispatch(workflowLoadedFromFile());
onSuccess && onSuccess();
onSuccess && onSuccess(workflow);
} catch (e) {
// There was a problem reading the file
logger.error(t('nodes.unableToLoadWorkflow'));
toast({
id: 'UNABLE_TO_LOAD_WORKFLOW',
title: t('nodes.unableToLoadWorkflow'),
status: 'error',
});
reader.abort();
}
};
@@ -48,7 +40,7 @@ export const useLoadWorkflowFromFile: UseLoadWorkflowFromFile = ({ resetRef, onS
// Reset the file picker internal state so that the same file can be loaded again
resetRef.current?.();
},
[dispatch, logger, resetRef, t, onSuccess]
[resetRef, loadWorkflow, dispatch, onSuccess]
);
return loadWorkflowFromFile;

View File

@@ -0,0 +1,79 @@
import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { formFieldInitialValuesChanged, workflowSaved } from 'features/nodes/store/workflowSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues';
import { workflowUpdated } from 'features/workflowLibrary/store/actions';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { SetFieldType, SetRequired } from 'type-fest';
/**
* A library workflow is a workflow that is already saved in the library. It has an id and is not in the default category.
*/
type LibraryWorkflow = SetFieldType<
SetRequired<WorkflowV3, 'id'>,
'meta',
SetFieldType<WorkflowV3['meta'], 'category', Exclude<WorkflowV3['meta']['category'], 'default'>>
>;
export const isLibraryWorkflow = (workflow: WorkflowV3): workflow is LibraryWorkflow =>
!!workflow.id && workflow.meta.category !== 'default';
type UseSaveLibraryWorkflowReturn = {
saveWorkflow: (workflow: LibraryWorkflow) => Promise<void>;
isLoading: boolean;
isError: boolean;
};
export const useSaveLibraryWorkflow = (): UseSaveLibraryWorkflowReturn => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const getFormFieldInitialValues = useGetFormFieldInitialValues();
const [updateWorkflow, { isLoading, isError }] = useUpdateWorkflowMutation();
const toast = useToast();
const toastRef = useRef<ToastId | undefined>();
const saveWorkflow = useCallback(
async (workflow: LibraryWorkflow) => {
toastRef.current = toast({
title: t('workflows.savingWorkflow'),
status: 'loading',
duration: null,
isClosable: false,
});
try {
await updateWorkflow(workflow).unwrap();
dispatch(workflowUpdated());
dispatch(workflowSaved());
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),
status: 'success',
duration: 1000,
isClosable: true,
});
} catch (e) {
if (!toast.isActive(`auth-error-toast-${workflowsApi.endpoints.updateWorkflow.name}`)) {
toast.update(toastRef.current, {
title: t('workflows.problemSavingWorkflow'),
status: 'error',
duration: 1000,
isClosable: true,
});
} else {
toast.close(toastRef.current);
}
}
},
[toast, t, dispatch, getFormFieldInitialValues, updateWorkflow]
);
return {
saveWorkflow,
isLoading,
isError,
};
};

View File

@@ -0,0 +1,24 @@
import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import { isLibraryWorkflow, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveLibraryWorkflow';
import { useCallback } from 'react';
/**
* Returns a function that saves the current workflow if it's a library workflow, or opens the save dialog.
*/
export const useSaveOrSaveAsWorkflow = () => {
const buildWorkflow = useBuildWorkflowFast();
const { saveWorkflow } = useSaveLibraryWorkflow();
const saveOrSaveAsWorkflow = useCallback(() => {
const workflow = buildWorkflow();
if (isLibraryWorkflow(workflow)) {
saveWorkflow(workflow);
} else {
saveWorkflowAs(workflow);
}
}, [buildWorkflow, saveWorkflow]);
return saveOrSaveAsWorkflow;
};

View File

@@ -1,82 +0,0 @@
import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import { formFieldInitialValuesChanged, workflowIDChanged, workflowSaved } from 'features/nodes/store/workflowSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues';
import { workflowUpdated } from 'features/workflowLibrary/store/actions';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateWorkflowMutation, useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { SetRequired } from 'type-fest';
type UseSaveLibraryWorkflowReturn = {
saveWorkflow: () => Promise<void>;
isLoading: boolean;
isError: boolean;
};
type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn;
export const isWorkflowWithID = (workflow: WorkflowV3): workflow is SetRequired<WorkflowV3, 'id'> =>
Boolean(workflow.id);
export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const getFormFieldInitialValues = useGetFormFieldInitialValues();
const [updateWorkflow, updateWorkflowResult] = useUpdateWorkflowMutation();
const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation();
const toast = useToast();
const toastRef = useRef<ToastId | undefined>();
const saveWorkflow = useCallback(async () => {
const workflow = $builtWorkflow.get();
if (!workflow) {
return;
}
toastRef.current = toast({
title: t('workflows.savingWorkflow'),
status: 'loading',
duration: null,
isClosable: false,
});
try {
if (isWorkflowWithID(workflow)) {
await updateWorkflow(workflow).unwrap();
dispatch(workflowUpdated());
} else {
const data = await createWorkflow(workflow).unwrap();
dispatch(workflowIDChanged(data.workflow.id));
}
dispatch(workflowSaved());
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),
status: 'success',
duration: 1000,
isClosable: true,
});
} catch (e) {
if (
!toast.isActive(`auth-error-toast-${workflowsApi.endpoints.createWorkflow.name}`) &&
!toast.isActive(`auth-error-toast-${workflowsApi.endpoints.updateWorkflow.name}`)
) {
toast.update(toastRef.current, {
title: t('workflows.problemSavingWorkflow'),
status: 'error',
duration: 1000,
isClosable: true,
});
} else {
toast.close(toastRef.current);
}
}
}, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow]);
return {
saveWorkflow,
isLoading: updateWorkflowResult.isLoading || createWorkflowResult.isLoading,
isError: updateWorkflowResult.isError || createWorkflowResult.isError,
};
};

View File

@@ -1 +0,0 @@
export const getWorkflowCopyName = (name: string): string => `${name.trim()} (copy)`;