feat(ui): updated workflow handling (WIP)

Clientside updates for the backend workflow changes.

Includes roughed-out workflow library UI.
This commit is contained in:
psychedelicious 2023-11-29 00:17:10 +11:00
parent a514c9e28b
commit 0b079df4ae
33 changed files with 886 additions and 377 deletions

View File

@ -161,7 +161,9 @@
"txt2img": "Text To Image",
"unifiedCanvas": "Unified Canvas",
"unknown": "Unknown",
"upload": "Upload"
"upload": "Upload",
"prevPage": "Previous Page",
"nextPage": "Next Page"
},
"controlnet": {
"controlAdapter_one": "Control Adapter",
@ -1599,5 +1601,12 @@
"showIntermediates": "Show Intermediates",
"snapToGrid": "Snap to Grid",
"undo": "Undo"
},
"workflows": {
"workflowLibrary": "Workflow Library",
"userCategory": "User",
"systemCategory": "System",
"loadWorkflow": "$t(nodes.loadWorkflow)",
"deleteWorkflow": "Delete Workflow"
}
}

View File

@ -3,6 +3,7 @@ import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
import { queueApi } from 'services/api/endpoints/queue';
import { BatchConfig } from 'services/api/types';
import { startAppListening } from '..';
import { buildWorkflow } from 'features/nodes/util/workflow/buildWorkflow';
export const addEnqueueRequestedNodes = () => {
startAppListening({
@ -11,9 +12,11 @@ export const addEnqueueRequestedNodes = () => {
effect: async (action, { getState, dispatch }) => {
const state = getState();
const graph = buildNodesGraph(state.nodes);
const workflow = buildWorkflow(state.nodes);
const batchConfig: BatchConfig = {
batch: {
graph,
workflow,
runs: state.generation.iterations,
},
prepend: action.payload.prepend,

View File

@ -99,7 +99,6 @@ export const addWorkflowLoadRequestedListener = () => {
);
} else {
// Some other error occurred
console.log(e);
log.error(
{ error: parseify(e) },
t('nodes.unknownErrorValidatingWorkflow')

View File

@ -16,6 +16,8 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { sentImageToImg2Img } from 'features/gallery/store/actions';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import ParamUpscalePopover from 'features/parameters/components/Parameters/Upscale/ParamUpscaleSettings';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
@ -39,12 +41,12 @@ import {
FaSeedling,
} from 'react-icons/fa';
import { FaCircleNodes, FaEllipsis } from 'react-icons/fa6';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import {
useGetImageDTOQuery,
useLazyGetImageWorkflowQuery,
} from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import { useDebouncedWorkflow } from 'services/api/hooks/useDebouncedWorkflow';
import { menuListMotionProps } from 'theme/components/menu';
import { sentImageToImg2Img } from 'features/gallery/store/actions';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
const currentImageButtonsSelector = createSelector(
[stateSelector, activeTabNameSelector],
@ -111,18 +113,17 @@ const CurrentImageButtons = () => {
lastSelectedImage?.image_name
);
const { workflow, isLoading: isLoadingWorkflow } = useDebouncedWorkflow(
lastSelectedImage?.workflow_id
);
const [getWorkflow, getWorkflowResult] = useLazyGetImageWorkflowQuery();
const handleLoadWorkflow = useCallback(() => {
if (!workflow) {
if (!lastSelectedImage) {
return;
}
dispatch(workflowLoadRequested(workflow));
}, [dispatch, workflow]);
getWorkflow(lastSelectedImage?.image_name).then((workflow) => {
dispatch(workflowLoadRequested(workflow.data));
});
}, [dispatch, getWorkflow, lastSelectedImage]);
useHotkeys('w', handleLoadWorkflow, [workflow]);
useHotkeys('w', handleLoadWorkflow, [lastSelectedImage]);
const handleClickUseAllParameters = useCallback(() => {
recallAllParameters(metadata);
@ -255,12 +256,12 @@ const CurrentImageButtons = () => {
<ButtonGroup isAttached={true} isDisabled={shouldDisableToolbarButtons}>
<IAIIconButton
isLoading={isLoadingWorkflow}
icon={<FaCircleNodes />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!workflow}
isDisabled={!imageDTO?.has_workflow}
onClick={handleLoadWorkflow}
isLoading={getWorkflowResult.isLoading}
/>
<IAIIconButton
isLoading={isLoadingMetadata}

View File

@ -3,17 +3,21 @@ import { useStore } from '@nanostores/react';
import { useAppToaster } from 'app/components/Toaster';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import {
imagesToChangeSelected,
isModalOpenChanged,
} from 'features/changeBoardModal/store/slice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import {
sentImageToCanvas,
sentImageToImg2Img,
} from 'features/gallery/store/actions';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { flushSync } from 'react-dom';
@ -32,16 +36,12 @@ import {
import { FaCircleNodes } from 'react-icons/fa6';
import { MdStar, MdStarBorder } from 'react-icons/md';
import {
useLazyGetImageWorkflowQuery,
useStarImagesMutation,
useUnstarImagesMutation,
} from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import { useDebouncedWorkflow } from 'services/api/hooks/useDebouncedWorkflow';
import { ImageDTO } from 'services/api/types';
import {
sentImageToCanvas,
sentImageToImg2Img,
} from 'features/gallery/store/actions';
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
@ -61,9 +61,13 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { metadata, isLoading: isLoadingMetadata } = useDebouncedMetadata(
imageDTO?.image_name
);
const { workflow, isLoading: isLoadingWorkflow } = useDebouncedWorkflow(
imageDTO?.workflow_id
);
const [getWorkflow, getWorkflowResult] = useLazyGetImageWorkflowQuery();
const handleLoadWorkflow = useCallback(() => {
getWorkflow(imageDTO.image_name).then((workflow) => {
dispatch(workflowLoadRequested(workflow.data));
});
}, [dispatch, getWorkflow, imageDTO]);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
@ -101,13 +105,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
recallSeed(metadata?.seed);
}, [metadata?.seed, recallSeed]);
const handleLoadWorkflow = useCallback(() => {
if (!workflow) {
return;
}
dispatch(workflowLoadRequested(workflow));
}, [dispatch, workflow]);
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(imageDTO));
@ -179,9 +176,9 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.downloadImage')}
</MenuItem>
<MenuItem
icon={isLoadingWorkflow ? <SpinnerIcon /> : <FaCircleNodes />}
icon={getWorkflowResult.isLoading ? <SpinnerIcon /> : <FaCircleNodes />}
onClickCapture={handleLoadWorkflow}
isDisabled={isLoadingWorkflow || !workflow}
isDisabled={!imageDTO.has_workflow}
>
{t('nodes.loadWorkflow')}
</MenuItem>

View File

@ -14,10 +14,10 @@ import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableCon
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import { useDebouncedWorkflow } from 'services/api/hooks/useDebouncedWorkflow';
import { ImageDTO } from 'services/api/types';
import DataViewer from './DataViewer';
import ImageMetadataActions from './ImageMetadataActions';
import ImageMetadataWorkflowTabContent from './ImageMetadataWorkflowTabContent';
type ImageMetadataViewerProps = {
image: ImageDTO;
@ -32,7 +32,6 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
const { t } = useTranslation();
const { metadata } = useDebouncedMetadata(image.image_name);
const { workflow } = useDebouncedWorkflow(image.workflow_id);
return (
<Flex
@ -67,9 +66,9 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
>
<TabList>
<Tab>{t('metadata.recallParameters')}</Tab>
<Tab>{t('metadata.metadata')}</Tab>
<Tab isDisabled={!metadata}>{t('metadata.metadata')}</Tab>
<Tab>{t('metadata.imageDetails')}</Tab>
<Tab>{t('metadata.workflow')}</Tab>
<Tab isDisabled={!image.has_workflow}>{t('metadata.workflow')}</Tab>
</TabList>
<TabPanels>
@ -97,11 +96,7 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
)}
</TabPanel>
<TabPanel>
{workflow ? (
<DataViewer data={workflow} label={t('metadata.workflow')} />
) : (
<IAINoContentFallback label={t('nodes.noWorkflow')} />
)}
<ImageMetadataWorkflowTabContent image={image} />
</TabPanel>
</TabPanels>
</Tabs>

View File

@ -0,0 +1,23 @@
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetImageWorkflowQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import DataViewer from './DataViewer';
type Props = {
image: ImageDTO;
};
const ImageMetadataWorkflowTabContent = ({ image }: Props) => {
const { t } = useTranslation();
const { currentData: workflow } = useGetImageWorkflowQuery(image.image_name);
if (!workflow) {
return <IAINoContentFallback label={t('nodes.noWorkflow')} />;
}
return <DataViewer data={workflow} label={t('metadata.workflow')} />;
};
export default memo(ImageMetadataWorkflowTabContent);

View File

@ -0,0 +1,25 @@
import { useDisclosure } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaFolderOpen } from 'react-icons/fa';
import WorkflowLibraryModal from './WorkflowLibraryModal';
const WorkflowLibraryButton = () => {
const { t } = useTranslation();
const { isOpen, onClose, onOpen } = useDisclosure();
return (
<>
<IAIIconButton
icon={<FaFolderOpen />}
onClick={onOpen}
tooltip={t('workflows.workflowLibrary')}
aria-label={t('workflows.workflowLibrary')}
/>
<WorkflowLibraryModal isOpen={isOpen} onClose={onClose} />
</>
);
};
export default memo(WorkflowLibraryButton);

View File

@ -0,0 +1,40 @@
import { ButtonGroup, Flex } from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import { WorkflowCategory } from './types';
import { Dispatch, SetStateAction, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
category: WorkflowCategory;
setCategory: Dispatch<SetStateAction<WorkflowCategory>>;
};
const WorkflowLibraryCategories = ({ category, setCategory }: Props) => {
const { t } = useTranslation();
const handleClickUser = useCallback(() => {
setCategory('user');
}, [setCategory]);
const handleClickSystem = useCallback(() => {
setCategory('system');
}, [setCategory]);
return (
<Flex layerStyle="second" p={2} borderRadius="base">
<ButtonGroup orientation="vertical">
<IAIButton
onClick={handleClickUser}
variant={category === 'user' ? 'invokeAI' : 'ghost'}
>
{t('workflows.userCategory')}
</IAIButton>
<IAIButton
onClick={handleClickSystem}
variant={category === 'system' ? 'invokeAI' : 'ghost'}
>
{t('workflows.systemCategory')}
</IAIButton>
</ButtonGroup>
</Flex>
);
};
export default memo(WorkflowLibraryCategories);

View File

@ -0,0 +1,38 @@
import { Flex } from '@chakra-ui/react';
import { WorkflowCategory } from './types';
import { Dispatch, SetStateAction, memo } from 'react';
import { paths } from 'services/api/schema';
import WorkflowLibraryCategories from './WorkflowLibraryCategories';
import WorkflowLibraryPagination from './WorkflowLibraryPagination';
import WorkflowLibraryList from './WorkflowLibraryList';
type Props = {
data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'];
category: WorkflowCategory;
setCategory: Dispatch<SetStateAction<WorkflowCategory>>;
page: number;
setPage: Dispatch<SetStateAction<number>>;
};
const WorkflowLibraryContent = ({
data,
category,
setCategory,
page,
setPage,
}: Props) => {
return (
<Flex w="full" h="full" gap={2}>
<WorkflowLibraryCategories
category={category}
setCategory={setCategory}
/>
<Flex h="full" w="full" gap={2} flexDir="column">
<WorkflowLibraryList data={data} />
<WorkflowLibraryPagination data={data} page={page} setPage={setPage} />
</Flex>
</Flex>
);
};
export default memo(WorkflowLibraryContent);

View File

@ -0,0 +1,45 @@
import { Flex, Spacer, Text } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaFolderOpen, FaTrash } from 'react-icons/fa';
import { paths } from 'services/api/schema';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
type Props = {
data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'];
};
const WorkflowLibraryList = ({ data }: Props) => {
const { t } = useTranslation();
return (
<Flex w="full" h="full" layerStyle="second" p={2} borderRadius="base">
<ScrollableContent>
<Flex w="full" h="full" gap={2} flexDir="column">
{data.items.map((w) => (
<Flex key={w.workflow_id} w="full">
<Flex w="full" alignItems="center" gap={2}>
<Text>{w.workflow_id}</Text>
<Spacer />
<IAIIconButton
icon={<FaFolderOpen />}
aria-label={t('workflows.loadWorkflow')}
tooltip={t('workflows.loadWorkflow')}
/>
<IAIIconButton
icon={<FaTrash />}
colorScheme="error"
aria-label={t('workflows.deleteWorkflow')}
tooltip={t('workflows.deleteWorkflow')}
/>
</Flex>
</Flex>
))}
</Flex>
</ScrollableContent>
</Flex>
);
};
export default memo(WorkflowLibraryList);

View File

@ -0,0 +1,43 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
} from '@chakra-ui/react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import WorkflowLibraryWrapper from './WorkflowLibraryWrapper';
type Props = {
isOpen: boolean;
onClose: () => void;
};
const WorkflowLibraryModal = ({ isOpen, onClose }: Props) => {
const { t } = useTranslation();
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<ModalOverlay />
<ModalContent
w="80%"
h="80%"
minW="unset"
minH="unset"
maxW="unset"
maxH="unset"
>
<ModalHeader>{t('workflows.workflowLibrary')}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<WorkflowLibraryWrapper />
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
);
};
export default memo(WorkflowLibraryModal);

View File

@ -0,0 +1,88 @@
import { ButtonGroup } from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import { Dispatch, SetStateAction, memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { paths } from 'services/api/schema';
const PAGES_TO_DISPLAY = 7;
type PageData = {
page: number;
onClick: () => void;
};
type Props = {
page: number;
setPage: Dispatch<SetStateAction<number>>;
data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'];
};
const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => {
const { t } = useTranslation();
const handlePrevPage = useCallback(() => {
setPage((p) => Math.max(p - 1, 0));
}, [setPage]);
const handleNextPage = useCallback(() => {
setPage((p) => Math.min(p + 1, data.pages - 1));
}, [data.pages, setPage]);
const pages: PageData[] = useMemo(() => {
const pages = [];
let first =
data.pages > PAGES_TO_DISPLAY
? Math.max(0, page - Math.floor(PAGES_TO_DISPLAY / 2))
: 0;
const last =
data.pages > PAGES_TO_DISPLAY
? Math.min(data.pages, first + PAGES_TO_DISPLAY)
: data.pages;
if (last - first < PAGES_TO_DISPLAY && data.pages > PAGES_TO_DISPLAY) {
first = last - PAGES_TO_DISPLAY;
}
for (let i = first; i < last; i++) {
pages.push({
page: i,
onClick: () => setPage(i),
});
}
return pages;
}, [data.pages, page, setPage]);
if (data.items.length === 0) {
return null;
}
return (
<ButtonGroup>
<IAIIconButton
onClick={handlePrevPage}
isDisabled={page === 0}
aria-label={t('common.prevPage')}
icon={<FaChevronLeft />}
/>
{pages.map((p) => (
<IAIButton
w={10}
onClick={p.page === page ? undefined : p.onClick}
variant={p.page === page ? 'invokeAI' : 'ghost'}
key={p.page}
transitionDuration="0s" // the delay in animation looks jank
>
{p.page + 1}
</IAIButton>
))}
<IAIIconButton
onClick={handleNextPage}
isDisabled={page === data.pages - 1}
aria-label={t('common.nextPage')}
icon={<FaChevronRight />}
/>
</ButtonGroup>
);
};
export default memo(WorkflowLibraryPagination);

View File

@ -0,0 +1,31 @@
import { memo, useState } from 'react';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import WorkflowLibraryContent from './WorkflowLibraryContent';
import { WorkflowCategory } from './types';
const PER_PAGE = 10;
const WorkflowLibraryWrapper = () => {
const [page, setPage] = useState(0);
const [category, setCategory] = useState<WorkflowCategory>('user');
const { data } = useListWorkflowsQuery({
page,
per_page: PER_PAGE,
});
if (!data) {
return null;
}
return (
<WorkflowLibraryContent
data={data}
page={page}
setPage={setPage}
category={category}
setCategory={setCategory}
/>
);
};
export default memo(WorkflowLibraryWrapper);

View File

@ -0,0 +1 @@
export type WorkflowCategory = 'user' | 'system';

View File

@ -1,45 +0,0 @@
import { Checkbox, Flex, FormControl, FormLabel } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEmbedWorkflow } from 'features/nodes/hooks/useEmbedWorkflow';
import { useWithWorkflow } from 'features/nodes/hooks/useWithWorkflow';
import { nodeEmbedWorkflowChanged } from 'features/nodes/store/nodesSlice';
import { ChangeEvent, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const EmbedWorkflowCheckbox = ({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const withWorkflow = useWithWorkflow(nodeId);
const embedWorkflow = useEmbedWorkflow(nodeId);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(
nodeEmbedWorkflowChanged({
nodeId,
embedWorkflow: e.target.checked,
})
);
},
[dispatch, nodeId]
);
if (!withWorkflow) {
return null;
}
return (
<FormControl as={Flex} sx={{ alignItems: 'center', gap: 2, w: 'auto' }}>
<FormLabel sx={{ fontSize: 'xs', mb: '1px' }}>
{t('metadata.workflow')}
</FormLabel>
<Checkbox
className="nopan"
size="sm"
onChange={handleChange}
isChecked={embedWorkflow}
/>
</FormControl>
);
};
export default memo(EmbedWorkflowCheckbox);

View File

@ -1,9 +1,8 @@
import { Flex } from '@chakra-ui/react';
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { memo } from 'react';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import EmbedWorkflowCheckbox from './EmbedWorkflowCheckbox';
import { memo } from 'react';
import SaveToGalleryCheckbox from './SaveToGalleryCheckbox';
import UseCacheCheckbox from './UseCacheCheckbox';
@ -28,7 +27,6 @@ const InvocationNodeFooter = ({ nodeId }: Props) => {
}}
>
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{hasImageOutput && <EmbedWorkflowCheckbox nodeId={nodeId} />}
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
</Flex>
);

View File

@ -3,6 +3,7 @@ import { memo } from 'react';
import LoadWorkflowButton from './LoadWorkflowButton';
import ResetWorkflowButton from './ResetWorkflowButton';
import DownloadWorkflowButton from './DownloadWorkflowButton';
import WorkflowLibraryButton from 'features/nodes/components/flow/WorkflowLibrary/WorkflowLibraryButton';
const TopCenterPanel = () => {
return (
@ -18,6 +19,7 @@ const TopCenterPanel = () => {
<DownloadWorkflowButton />
<LoadWorkflowButton />
<ResetWorkflowButton />
<WorkflowLibraryButton />
</Flex>
);
};

View File

@ -11,17 +11,16 @@ import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { getNeedsUpdate } from 'features/nodes/util/node/nodeUpdate';
import NotesTextarea from 'features/nodes/components/flow/nodes/Invocation/NotesTextarea';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import {
InvocationNodeData,
InvocationNode,
InvocationTemplate,
isInvocationNode,
} from 'features/nodes/types/invocation';
import { getNeedsUpdate } from 'features/nodes/util/node/nodeUpdate';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Node } from 'reactflow';
import NotesTextarea from 'features/nodes/components/flow/nodes/Invocation/NotesTextarea';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import EditableNodeTitle from './details/EditableNodeTitle';
const selector = createSelector(
@ -62,7 +61,7 @@ const InspectorDetailsTab = () => {
export default memo(InspectorDetailsTab);
type ContentProps = {
node: Node<InvocationNodeData>;
node: InvocationNode;
template: InvocationTemplate;
};

View File

@ -1,27 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useMemo } from 'react';
import { isInvocationNode } from 'features/nodes/types/invocation';
export const useEmbedWorkflow = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return false;
}
return node.data.embedWorkflow;
},
defaultSelectorOptions
),
[nodeId]
);
const embedWorkflow = useAppSelector(selector);
return embedWorkflow;
};

View File

@ -1,31 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useMemo } from 'react';
import { isInvocationNode } from 'features/nodes/types/invocation';
export const useWithWorkflow = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return false;
}
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
if (!nodeTemplate) {
return false;
}
return nodeTemplate.withWorkflow;
},
defaultSelectorOptions
),
[nodeId]
);
const withWorkflow = useAppSelector(selector);
return withWorkflow;
};

View File

@ -344,20 +344,6 @@ const nodesSlice = createSlice({
}
field.label = label;
},
nodeEmbedWorkflowChanged: (
state,
action: PayloadAction<{ nodeId: string; embedWorkflow: boolean }>
) => {
const { nodeId, embedWorkflow } = action.payload;
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
const node = state.nodes?.[nodeIndex];
if (!isInvocationNode(node)) {
return;
}
node.data.embedWorkflow = embedWorkflow;
},
nodeUseCacheChanged: (
state,
action: PayloadAction<{ nodeId: string; useCache: boolean }>
@ -984,7 +970,6 @@ export const {
nodeAdded,
nodeReplaced,
nodeEditorReset,
nodeEmbedWorkflowChanged,
nodeExclusivelySelected,
nodeIsIntermediateChanged,
nodeIsOpenChanged,

View File

@ -18,7 +18,6 @@ export const zInvocationTemplate = z.object({
inputs: z.record(zFieldInputTemplate),
outputs: z.record(zFieldOutputTemplate),
outputType: z.string().min(1),
withWorkflow: z.boolean(),
version: zSemVer,
useCache: z.boolean(),
nodePack: z.string().min(1).nullish(),
@ -33,7 +32,6 @@ export const zInvocationNodeData = z.object({
label: z.string(),
isOpen: z.boolean(),
notes: z.string(),
embedWorkflow: z.boolean(),
isIntermediate: z.boolean(),
useCache: z.boolean(),
version: zSemVer,

View File

@ -73,6 +73,7 @@ export type WorkflowEdge = z.infer<typeof zWorkflowEdge>;
// #region Workflow
export const zWorkflowV2 = z.object({
id: z.string().min(1).optional(),
name: z.string(),
author: z.string(),
description: z.string(),

View File

@ -1,14 +1,13 @@
import { NodesState } from 'features/nodes/store/types';
import {
FieldInputInstance,
isColorFieldInputInstance,
} from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { cloneDeep, omit, reduce } from 'lodash-es';
import { Graph } from 'services/api/types';
import { AnyInvocation } from 'services/events/types';
import { v4 as uuidv4 } from 'uuid';
import { buildWorkflow } from 'features/nodes/util/workflow/buildWorkflow';
import {
FieldInputInstance,
isColorFieldInputInstance,
} from 'features/nodes/types/field';
/**
* We need to do special handling for some fields
@ -44,7 +43,7 @@ export const buildNodesGraph = (nodesState: NodesState): Graph => {
const parsedNodes = filteredNodes.reduce<NonNullable<Graph['nodes']>>(
(nodesAccumulator, node) => {
const { id, data } = node;
const { type, inputs, isIntermediate, embedWorkflow } = data;
const { type, inputs, isIntermediate } = data;
// Transform each node's inputs to simple key-value pairs
const transformedInputs = reduce(
@ -69,11 +68,6 @@ export const buildNodesGraph = (nodesState: NodesState): Graph => {
is_intermediate: isIntermediate,
};
if (embedWorkflow) {
// add the workflow to the node
Object.assign(graphNode, { workflow: buildWorkflow(nodesState) });
}
// Add it to the nodes object
Object.assign(nodesAccumulator, {
[id]: graphNode,

View File

@ -67,7 +67,6 @@ export const buildInvocationNode = (
label: '',
notes: '',
isOpen: true,
embedWorkflow: false,
isIntermediate: type === 'save_image' ? false : true,
useCache: template.useCache,
inputs,

View File

@ -1,16 +1,15 @@
import { satisfies } from 'compare-versions';
import { NodeUpdateError } from 'features/nodes/types/error';
import {
InvocationNodeData,
InvocationNode,
InvocationTemplate,
} from 'features/nodes/types/invocation';
import { zParsedSemver } from 'features/nodes/types/semver';
import { cloneDeep, defaultsDeep } from 'lodash-es';
import { Node } from 'reactflow';
import { cloneDeep, keys, defaultsDeep, pick } from 'lodash-es';
import { buildInvocationNode } from './buildInvocationNode';
export const getNeedsUpdate = (
node: Node<InvocationNodeData>,
node: InvocationNode,
template: InvocationTemplate
): boolean => {
if (node.data.type !== template.type) {
@ -24,7 +23,7 @@ export const getNeedsUpdate = (
*/
export const getMayUpdateNode = (
node: Node<InvocationNodeData>,
node: InvocationNode,
template: InvocationTemplate
): boolean => {
const needsUpdate = getNeedsUpdate(node, template);
@ -45,9 +44,9 @@ export const getMayUpdateNode = (
*/
export const updateNode = (
node: Node<InvocationNodeData>,
node: InvocationNode,
template: InvocationTemplate
): Node<InvocationNodeData> => {
): InvocationNode => {
const mayUpdate = getMayUpdateNode(node, template);
if (!mayUpdate || node.data.type !== template.type) {
@ -64,5 +63,8 @@ export const updateNode = (
clone.data.version = template.version;
defaultsDeep(clone, defaults); // mutates!
// Remove any fields that are not in the template
clone.data.inputs = pick(clone.data.inputs, keys(defaults.data.inputs));
clone.data.outputs = pick(clone.data.outputs, keys(defaults.data.outputs));
return clone;
};

View File

@ -86,7 +86,6 @@ export const parseSchema = (
const description = schema.description ?? '';
const version = schema.version;
const nodePack = schema.node_pack;
let withWorkflow = false;
const inputs = reduce(
schema.properties,
@ -114,12 +113,6 @@ export const parseSchema = (
try {
const fieldType = parseFieldType(property);
if (fieldType.name === 'WorkflowField') {
// This supports workflows, set the flag and skip to next field
withWorkflow = true;
return inputsAccumulator;
}
if (isReservedFieldType(fieldType.name)) {
// Skip processing this reserved field
return inputsAccumulator;
@ -260,7 +253,6 @@ export const parseSchema = (
inputs,
outputs,
useCache,
withWorkflow,
nodePack,
};

View File

@ -1,18 +1,18 @@
import { $store } from 'app/store/nanostores/store';
import { RootState } from 'app/store/store';
import { FieldType } from 'features/nodes/types/field';
import { InvocationNodeData } from 'features/nodes/types/invocation';
import { t } from 'i18next';
import { forEach } from 'lodash-es';
import { z } from 'zod';
import {
WorkflowMigrationError,
WorkflowVersionError,
} from 'features/nodes/types/error';
import { FieldType } from 'features/nodes/types/field';
import { InvocationNodeData } from 'features/nodes/types/invocation';
import { zSemVer } from 'features/nodes/types/semver';
import { FIELD_TYPE_V1_TO_FIELD_TYPE_V2_MAPPING } from 'features/nodes/types/v1/fieldTypeMap';
import { WorkflowV1, zWorkflowV1 } from 'features/nodes/types/v1/workflowV1';
import { WorkflowV2, zWorkflowV2 } from 'features/nodes/types/workflow';
import { t } from 'i18next';
import { forEach } from 'lodash-es';
import { z } from 'zod';
/**
* Helper schema to extract the version from a workflow.
@ -25,6 +25,11 @@ const zWorkflowMetaVersion = z.object({
/**
* Migrates a workflow from V1 to V2.
*
* Changes include:
* - Field types are now structured
* - Invocation node pack is now saved in the node data
* - Workflow schema version bumped to 2.0.0
*/
const migrateV1toV2 = (workflowToMigrate: WorkflowV1): WorkflowV2 => {
const invocationTemplates = ($store.get()?.getState() as RootState).nodes
@ -39,7 +44,6 @@ const migrateV1toV2 = (workflowToMigrate: WorkflowV1): WorkflowV2 => {
t('nodes.unknownFieldType', { type: input.type })
);
}
// Cast as the V2 type
(input.type as unknown as FieldType) = newFieldType;
});
forEach(node.data.outputs, (output) => {
@ -50,19 +54,19 @@ const migrateV1toV2 = (workflowToMigrate: WorkflowV1): WorkflowV2 => {
t('nodes.unknownFieldType', { type: output.type })
);
}
// Cast as the V2 type
(output.type as unknown as FieldType) = newFieldType;
});
// Migrate nodePack
// Add node pack
const invocationTemplate = invocationTemplates[node.data.type];
const nodePack = invocationTemplate
? invocationTemplate.nodePack
: t('common.unknown');
// Cast as the V2 type
(node.data as unknown as InvocationNodeData).nodePack = nodePack;
}
});
(workflowToMigrate.meta.version as WorkflowV2['meta']['version']) = '2.0.0';
// Bump version
(workflowToMigrate as unknown as WorkflowV2).meta.version = '2.0.0';
// Parsing strips out any extra properties not in the latest version
return zWorkflowV2.parse(workflowToMigrate);
};
@ -73,7 +77,6 @@ export const parseAndMigrateWorkflow = (data: unknown): WorkflowV2 => {
const workflowVersionResult = zWorkflowMetaVersion.safeParse(data);
if (!workflowVersionResult.success) {
console.log(data);
throw new WorkflowVersionError(t('nodes.unableToGetWorkflowVersion'));
}

View File

@ -9,7 +9,6 @@ import {
} from 'features/gallery/store/types';
import { CoreMetadata, zCoreMetadata } from 'features/nodes/types/metadata';
import { keyBy } from 'lodash-es';
import { ApiTagDescription, LIST_TAG, api } from '..';
import { components, paths } from 'services/api/schema';
import {
DeleteBoardResult,
@ -26,6 +25,7 @@ import {
imagesAdapter,
imagesSelectors,
} from 'services/api/util';
import { ApiTagDescription, LIST_TAG, api } from '..';
import { boardsApi } from './boards';
export const imagesApi = api.injectEndpoints({
@ -128,6 +128,16 @@ export const imagesApi = api.injectEndpoints({
},
keepUnusedDataFor: 86400, // 24 hours
}),
getImageWorkflow: build.query<
paths['/api/v1/images/i/{image_name}/workflow']['get']['responses']['200']['content']['application/json'],
string
>({
query: (image_name) => ({ url: `images/i/${image_name}/workflow` }),
providesTags: (result, error, image_name) => [
{ type: 'ImageWorkflow', id: image_name },
],
keepUnusedDataFor: 86400, // 24 hours
}),
deleteImage: build.mutation<void, ImageDTO>({
query: ({ image_name }) => ({
url: `images/i/${image_name}`,
@ -1560,6 +1570,8 @@ export const {
useLazyListImagesQuery,
useGetImageDTOQuery,
useGetImageMetadataQuery,
useGetImageWorkflowQuery,
useLazyGetImageWorkflowQuery,
useDeleteImageMutation,
useDeleteImagesMutation,
useUploadImageMutation,

View File

@ -1,30 +1,70 @@
import { logger } from 'app/logging/logger';
import { WorkflowV2, zWorkflowV2 } from 'features/nodes/types/workflow';
import { api } from '..';
import { WorkflowV2 } from 'features/nodes/types/workflow';
import { paths } from 'services/api/schema';
import { LIST_TAG, api } from '..';
export const workflowsApi = api.injectEndpoints({
endpoints: (build) => ({
getWorkflow: build.query<WorkflowV2 | undefined, string>({
getWorkflow: build.query<
paths['/api/v1/workflows/i/{workflow_id}']['get']['responses']['200']['content']['application/json'],
string
>({
query: (workflow_id) => `workflows/i/${workflow_id}`,
providesTags: (result, error, workflow_id) => [
{ type: 'Workflow', id: workflow_id },
],
transformResponse: (
response: paths['/api/v1/workflows/i/{workflow_id}']['get']['responses']['200']['content']['application/json']
) => {
if (response) {
const result = zWorkflowV2.safeParse(response);
if (result.success) {
return result.data;
} else {
logger('images').warn('Problem parsing workflow');
}
}
return;
},
}),
deleteWorkflow: build.mutation<void, string>({
query: (workflow_id) => ({
url: `workflows/i/${workflow_id}`,
method: 'DELETE',
}),
invalidatesTags: [{ type: 'Workflow', id: LIST_TAG }],
}),
createWorkflow: build.mutation<
paths['/api/v1/workflows/']['post']['responses']['200']['content']['application/json'],
WorkflowV2
>({
query: (workflow) => ({
url: 'workflows',
method: 'POST',
body: workflow,
}),
invalidatesTags: [{ type: 'Workflow', id: LIST_TAG }],
}),
updateWorkflow: build.mutation<
paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'],
WorkflowV2
>({
query: (workflow) => ({
url: `workflows/i/${workflow.id}`,
method: 'PATCH',
body: workflow,
}),
invalidatesTags: (response, error, arg) => [
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: arg.id },
],
}),
listWorkflows: build.query<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
NonNullable<paths['/api/v1/workflows/']['get']['parameters']['query']>
>({
query: (params) => ({
url: 'workflows/',
params,
}),
providesTags: (result, error, params) => [
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: params?.page },
],
}),
}),
});
export const { useGetWorkflowQuery } = workflowsApi;
export const {
useGetWorkflowQuery,
useCreateWorkflowMutation,
useDeleteWorkflowMutation,
useUpdateWorkflowMutation,
useListWorkflowsQuery,
} = workflowsApi;

View File

@ -20,6 +20,7 @@ export const tagTypes = [
'ImageNameList',
'ImageList',
'ImageMetadata',
'ImageWorkflow',
'ImageMetadataFromFile',
'IntermediatesCount',
'SessionQueueItem',

File diff suppressed because one or more lines are too long