Merge branch 'main' into fix-civit-model-imports

This commit is contained in:
Brandon 2024-01-31 10:12:44 -05:00 committed by GitHub
commit ed466a99ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 362 additions and 98 deletions

View File

@ -1014,6 +1014,9 @@
"newWorkflow": "New Workflow", "newWorkflow": "New Workflow",
"newWorkflowDesc": "Create a new workflow?", "newWorkflowDesc": "Create a new workflow?",
"newWorkflowDesc2": "Your current workflow has unsaved changes.", "newWorkflowDesc2": "Your current workflow has unsaved changes.",
"clearWorkflow": "Clear Workflow",
"clearWorkflowDesc": "Clear this workflow and start a new one?",
"clearWorkflowDesc2": "Your current workflow has unsaved changes.",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"schedulerDescription": "TODO", "schedulerDescription": "TODO",
"sDXLMainModelField": "SDXL Model", "sDXLMainModelField": "SDXL Model",
@ -1698,6 +1701,7 @@
"downloadWorkflow": "Save to File", "downloadWorkflow": "Save to File",
"saveWorkflow": "Save Workflow", "saveWorkflow": "Save Workflow",
"saveWorkflowAs": "Save Workflow As", "saveWorkflowAs": "Save Workflow As",
"saveWorkflowToProject": "Save Workflow to Project",
"savingWorkflow": "Saving Workflow...", "savingWorkflow": "Saving Workflow...",
"problemSavingWorkflow": "Problem Saving Workflow", "problemSavingWorkflow": "Problem Saving Workflow",
"workflowSaved": "Workflow Saved", "workflowSaved": "Workflow Saved",
@ -1712,6 +1716,7 @@
"clearWorkflowSearchFilter": "Clear Workflow Search Filter", "clearWorkflowSearchFilter": "Clear Workflow Search Filter",
"workflowName": "Workflow Name", "workflowName": "Workflow Name",
"newWorkflowCreated": "New Workflow Created", "newWorkflowCreated": "New Workflow Created",
"workflowCleared": "Workflow Cleared",
"workflowEditorMenu": "Workflow Editor Menu", "workflowEditorMenu": "Workflow Editor Menu",
"workflowIsOpen": "Workflow is Open" "workflowIsOpen": "Workflow is Open"
}, },

View File

@ -23,9 +23,7 @@ export type AppFeature =
| 'resumeQueue' | 'resumeQueue'
| 'prependQueue' | 'prependQueue'
| 'invocationCache' | 'invocationCache'
| 'bulkDownload' | 'bulkDownload';
| 'workflowLibrary';
/** /**
* A disable-able Stable Diffusion feature * A disable-able Stable Diffusion feature
*/ */

View File

@ -4,6 +4,7 @@ import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
import type { AnimationProps } from 'framer-motion'; import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
@ -59,6 +60,7 @@ const NodeEditor = () => {
<TopPanel /> <TopPanel />
<BottomLeftPanel /> <BottomLeftPanel />
<MinimapPanel /> <MinimapPanel />
<SaveWorkflowAsDialog />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@ -179,6 +179,7 @@ const AddNodePopover = () => {
closeOnBlur={true} closeOnBlur={true}
returnFocusOnClose={true} returnFocusOnClose={true}
initialFocusRef={inputRef} initialFocusRef={inputRef}
isLazy
> >
<PopoverAnchor> <PopoverAnchor>
<Flex position="absolute" top="15%" insetInlineStart="50%" pointerEvents="none" /> <Flex position="absolute" top="15%" insetInlineStart="50%" pointerEvents="none" />

View File

@ -0,0 +1,64 @@
import { ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
import { addToast } from '../../../../../system/store/systemSlice';
import { makeToast } from '../../../../../system/util/makeToast';
import { nodeEditorReset } from '../../../../store/nodesSlice';
const ClearFlowButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const handleNewWorkflow = useCallback(() => {
dispatch(nodeEditorReset());
dispatch(
addToast(
makeToast({
title: t('workflows.workflowCleared'),
status: 'success',
})
)
);
onClose();
}, [dispatch, onClose, t]);
const onClick = useCallback(() => {
if (!isTouched) {
handleNewWorkflow();
return;
}
onOpen();
}, [handleNewWorkflow, isTouched, onOpen]);
return (
<>
<IconButton
tooltip={t('nodes.clearWorkflow')}
aria-label={t('nodes.clearWorkflow')}
icon={<PiTrashSimpleFill />}
onClick={onClick}
pointerEvents="auto"
/>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('nodes.clearWorkflow')}
acceptCallback={handleNewWorkflow}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.clearWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.clearWorkflowDesc2')}</Text>
</Flex>
</ConfirmationAlertDialog>
</>
);
};
export default memo(ClearFlowButton);

View File

@ -0,0 +1,42 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { isWorkflowWithID, useSaveLibraryWorkflow } from '../../../../../workflowLibrary/hooks/useSaveWorkflow';
import { $builtWorkflow } from '../../../../hooks/useWorkflowWatcher';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const { onOpen } = useSaveWorkflowAsDialog();
const { saveWorkflow } = useSaveLibraryWorkflow();
const handleClickSave = useCallback(async () => {
const builtWorkflow = $builtWorkflow.get();
if (!builtWorkflow) {
return;
}
if (isWorkflowWithID(builtWorkflow)) {
saveWorkflow();
} else {
onOpen();
}
}, [onOpen, saveWorkflow]);
return (
<IconButton
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={<PiFloppyDiskBold />}
isDisabled={!isTouched}
onClick={handleClickSave}
pointerEvents="auto"
/>
);
};
export default memo(SaveWorkflowButton);

View File

@ -1,23 +1,28 @@
import { Flex, Spacer } from '@invoke-ai/ui-library'; import { Flex, Spacer } from '@invoke-ai/ui-library';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton'; 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 UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName'; import WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react'; import { memo } from 'react';
const TopCenterPanel = () => { const TopCenterPanel = () => {
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
return ( return (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="center" pointerEvents="none"> <Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex flexDir="column" gap="2">
<Flex gap="2">
<AddNodeButton /> <AddNodeButton />
<WorkflowLibraryButton />
</Flex>
<UpdateNodesButton /> <UpdateNodesButton />
</Flex>
<Spacer /> <Spacer />
<WorkflowName /> <WorkflowName />
<Spacer /> <Spacer />
{isWorkflowLibraryEnabled && <WorkflowLibraryButton />} <ClearFlowButton />
<SaveWorkflowButton />
<WorkflowLibraryMenu /> <WorkflowLibraryMenu />
</Flex> </Flex>
); );

View File

@ -1,4 +1,4 @@
import { Button } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate'; import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate';
import { updateAllNodesRequested } from 'features/nodes/store/actions'; import { updateAllNodesRequested } from 'features/nodes/store/actions';
@ -19,9 +19,13 @@ const UpdateNodesButton = () => {
} }
return ( return (
<Button leftIcon={<PiWarningBold />} onClick={handleClickUpdateNodes} pointerEvents="auto"> <IconButton
{t('nodes.updateAllNodes')} tooltip={t('nodes.updateAllNodes')}
</Button> aria-label={t('nodes.updateAllNodes')}
icon={<PiWarningBold />}
onClick={handleClickUpdateNodes}
pointerEvents="auto"
/>
); );
}; };

View File

@ -1,26 +1,13 @@
import { Text } from '@invoke-ai/ui-library'; import { Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const TopCenterPanel = () => { const TopCenterPanel = () => {
const { t } = useTranslation();
const name = useAppSelector((s) => s.workflow.name); const name = useAppSelector((s) => s.workflow.name);
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
const displayName = useMemo(() => {
let _displayName = name || t('workflows.unnamedWorkflow');
if (isTouched && isWorkflowLibraryEnabled) {
_displayName += ` (${t('common.unsaved')})`;
}
return _displayName;
}, [t, name, isTouched, isWorkflowLibraryEnabled]);
return ( return (
<Text m={2} fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold" opacity={0.8}> <Text m={2} fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold" opacity={0.8}>
{displayName} {name}
</Text> </Text>
); );
}; };

View File

@ -1,15 +1,12 @@
import { Flex } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react'; import { memo } from 'react';
const TopRightPanel = () => { const TopRightPanel = () => {
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
return ( return (
<Flex gap={2} position="absolute" top={2} insetInlineEnd={2}> <Flex gap={2} position="absolute" top={2} insetInlineEnd={2}>
{isWorkflowLibraryEnabled && <WorkflowLibraryButton />} <WorkflowLibraryButton />
<WorkflowLibraryMenu /> <WorkflowLibraryMenu />
</Flex> </Flex>
); );

View File

@ -5,7 +5,7 @@ import { workflowLoaded } from 'features/nodes/store/actions';
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice'; import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice';
import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types'; import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import type { FieldIdentifier } from 'features/nodes/types/field'; import type { FieldIdentifier } from 'features/nodes/types/field';
import type { WorkflowV2 } from 'features/nodes/types/workflow'; import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow';
import { cloneDeep, isEqual, uniqBy } from 'lodash-es'; import { cloneDeep, isEqual, uniqBy } from 'lodash-es';
export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = { export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
@ -46,6 +46,11 @@ const workflowSlice = createSlice({
state.name = action.payload; state.name = action.payload;
state.isTouched = true; state.isTouched = true;
}, },
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;
}
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => { workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload; state.description = action.payload;
state.isTouched = true; state.isTouched = true;
@ -102,6 +107,7 @@ export const {
workflowExposedFieldAdded, workflowExposedFieldAdded,
workflowExposedFieldRemoved, workflowExposedFieldRemoved,
workflowNameChanged, workflowNameChanged,
workflowCategoryChanged,
workflowDescriptionChanged, workflowDescriptionChanged,
workflowTagsChanged, workflowTagsChanged,
workflowAuthorChanged, workflowAuthorChanged,

View File

@ -0,0 +1,102 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
Checkbox,
Flex,
FormControl,
FormLabel,
Input,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { t } from 'i18next';
import type { ChangeEvent } from 'react';
import { useCallback, useRef } from 'react';
import { $workflowCategories } from '../../../../app/store/nanostores/workflowCategories';
import { useSaveWorkflowAs } from '../../hooks/useSaveWorkflowAs';
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

@ -0,0 +1,52 @@
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,4 +1,4 @@
import { Button, useDisclosure } from '@invoke-ai/ui-library'; import { IconButton, useDisclosure } from '@invoke-ai/ui-library';
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext'; import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -12,9 +12,13 @@ const WorkflowLibraryButton = () => {
return ( return (
<WorkflowLibraryModalContext.Provider value={disclosure}> <WorkflowLibraryModalContext.Provider value={disclosure}>
<Button leftIcon={<PiBooksBold />} onClick={disclosure.onOpen} pointerEvents="auto"> <IconButton
{t('workflows.workflowLibrary')} aria-label={t('workflows.workflowLibrary')}
</Button> tooltip={t('workflows.workflowLibrary')}
icon={<PiBooksBold />}
onClick={disclosure.onOpen}
pointerEvents="auto"
/>
<WorkflowLibraryModal /> <WorkflowLibraryModal />
</WorkflowLibraryModalContext.Provider> </WorkflowLibraryModalContext.Provider>
); );

View File

@ -1,53 +1,18 @@
import { ConfirmationAlertDialog, FormControl, FormLabel, Input, MenuItem, useDisclosure } from '@invoke-ai/ui-library'; import { MenuItem } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks'; import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { useSaveWorkflowAs } from 'features/workflowLibrary/hooks/useSaveWorkflowAs'; import { memo } from 'react';
import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiCopyBold } from 'react-icons/pi'; import { PiCopyBold } from 'react-icons/pi';
const SaveWorkflowAsButton = () => { const SaveWorkflowAsMenuItem = () => {
const currentName = useAppSelector((s) => s.workflow.name);
const { t } = useTranslation(); const { t } = useTranslation();
const { saveWorkflowAs } = useSaveWorkflowAs(); const { onOpen } = useSaveWorkflowAsDialog();
const [name, setName] = useState(getWorkflowCopyName(currentName));
const { isOpen, onOpen, onClose } = useDisclosure();
const inputRef = useRef<HTMLInputElement>(null);
const onOpenCallback = useCallback(() => {
setName(getWorkflowCopyName(currentName));
onOpen();
inputRef.current?.focus();
}, [currentName, onOpen]);
const onSave = useCallback(async () => {
saveWorkflowAs({ name, onSuccess: onClose, onError: onClose });
}, [name, onClose, saveWorkflowAs]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
}, []);
return ( return (
<> <MenuItem as="button" icon={<PiCopyBold />} onClick={onOpen}>
<MenuItem as="button" icon={<PiCopyBold />} onClick={onOpenCallback}>
{t('workflows.saveWorkflowAs')} {t('workflows.saveWorkflowAs')}
</MenuItem> </MenuItem>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('workflows.saveWorkflowAs')}
acceptCallback={onSave}
>
<FormControl>
<FormLabel>{t('workflows.workflowName')}</FormLabel>
<Input ref={inputRef} value={name} onChange={onChange} placeholder={t('workflows.workflowName')} />
</FormControl>
</ConfirmationAlertDialog>
</>
); );
}; };
export default memo(SaveWorkflowAsButton); export default memo(SaveWorkflowAsMenuItem);

View File

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

View File

@ -7,7 +7,6 @@ import {
useDisclosure, useDisclosure,
useGlobalMenuClose, useGlobalMenuClose,
} from '@invoke-ai/ui-library'; } from '@invoke-ai/ui-library';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem'; 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 SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
@ -22,9 +21,6 @@ const WorkflowLibraryMenu = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
useGlobalMenuClose(onClose); useGlobalMenuClose(onClose);
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
return ( return (
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose}> <Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<MenuButton <MenuButton
@ -34,11 +30,12 @@ const WorkflowLibraryMenu = () => {
pointerEvents="auto" pointerEvents="auto"
/> />
<MenuList pointerEvents="auto"> <MenuList pointerEvents="auto">
{isWorkflowLibraryEnabled && <SaveWorkflowMenuItem />}
{isWorkflowLibraryEnabled && <SaveWorkflowAsMenuItem />}
<DownloadWorkflowMenuItem />
<UploadWorkflowMenuItem />
<NewWorkflowMenuItem /> <NewWorkflowMenuItem />
<UploadWorkflowMenuItem />
<MenuDivider />
<SaveWorkflowMenuItem />
<SaveWorkflowAsMenuItem />
<DownloadWorkflowMenuItem />
<MenuDivider /> <MenuDivider />
<SettingsMenuItem /> <SettingsMenuItem />
</MenuList> </MenuList>

View File

@ -17,7 +17,8 @@ type UseSaveLibraryWorkflowReturn = {
type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn; type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn;
const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required<WorkflowV2, 'id'> => Boolean(workflow.id); export const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required<WorkflowV2, 'id'> =>
Boolean(workflow.id);
export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@ -2,13 +2,21 @@ import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import { workflowIDChanged, workflowNameChanged, workflowSaved } from 'features/nodes/store/workflowSlice'; import {
workflowCategoryChanged,
workflowIDChanged,
workflowNameChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { WorkflowCategory } from '../../nodes/types/workflow';
type SaveWorkflowAsArg = { type SaveWorkflowAsArg = {
name: string; name: string;
category: WorkflowCategory;
onSuccess?: () => void; onSuccess?: () => void;
onError?: () => void; onError?: () => void;
}; };
@ -28,7 +36,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
const toast = useToast(); const toast = useToast();
const toastRef = useRef<ToastId | undefined>(); const toastRef = useRef<ToastId | undefined>();
const saveWorkflowAs = useCallback( const saveWorkflowAs = useCallback(
async ({ name: newName, onSuccess, onError }: SaveWorkflowAsArg) => { async ({ name: newName, category, onSuccess, onError }: SaveWorkflowAsArg) => {
const workflow = $builtWorkflow.get(); const workflow = $builtWorkflow.get();
if (!workflow) { if (!workflow) {
return; return;
@ -42,10 +50,14 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
try { try {
workflow.id = undefined; workflow.id = undefined;
workflow.name = newName; workflow.name = newName;
workflow.meta.category = category;
const data = await createWorkflow(workflow).unwrap(); const data = await createWorkflow(workflow).unwrap();
dispatch(workflowIDChanged(data.workflow.id)); dispatch(workflowIDChanged(data.workflow.id));
dispatch(workflowNameChanged(data.workflow.name)); dispatch(workflowNameChanged(data.workflow.name));
dispatch(workflowCategoryChanged(data.workflow.meta.category));
dispatch(workflowSaved()); dispatch(workflowSaved());
onSuccess && onSuccess(); onSuccess && onSuccess();
toast.update(toastRef.current, { toast.update(toastRef.current, {
title: t('workflows.workflowSaved'), title: t('workflows.workflowSaved'),