feat: workflow library WIP

- Save to library
- Duplicate
- Filter/sort
- UI/queries
This commit is contained in:
psychedelicious 2023-12-01 15:24:22 +11:00
parent 46905175a9
commit 0a25efd054
43 changed files with 908 additions and 345 deletions

View File

@ -1,12 +1,16 @@
from typing import Optional
from fastapi import APIRouter, Body, HTTPException, Path, Query
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.workflow_records.workflow_records_common import (
Workflow,
WorkflowNotFoundError,
WorkflowRecordDTO,
WorkflowRecordListItemDTO,
WorkflowRecordOrderBy,
WorkflowWithoutID,
)
@ -79,6 +83,11 @@ async def create_workflow(
async def list_workflows(
page: int = Query(default=0, description="The page to get"),
per_page: int = Query(default=10, description="The number of workflows per page"),
order_by: WorkflowRecordOrderBy = Query(default=WorkflowRecordOrderBy.Name, description="The order by"),
direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The order by"),
filter_text: Optional[str] = Query(default=None, description="The name to filter by"),
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets a page of workflows"""
return ApiDependencies.invoker.services.workflow_records.get_many(page=page, per_page=per_page)
return ApiDependencies.invoker.services.workflow_records.get_many(
page=page, per_page=per_page, order_by=order_by, direction=direction, filter_text=filter_text
)

View File

@ -1 +1,10 @@
from enum import Enum
from invokeai.app.util.metaenum import MetaEnum
sqlite_memory = ":memory:"
class SQLiteDirection(str, Enum, metaclass=MetaEnum):
Ascending = "ASC"
Descending = "DESC"

View File

@ -1,10 +1,13 @@
from abc import ABC, abstractmethod
from typing import Optional
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.workflow_records.workflow_records_common import (
Workflow,
WorkflowRecordDTO,
WorkflowRecordListItemDTO,
WorkflowRecordOrderBy,
WorkflowWithoutID,
)
@ -33,6 +36,13 @@ class WorkflowRecordsStorageBase(ABC):
pass
@abstractmethod
def get_many(self, page: int, per_page: int) -> PaginatedResults[WorkflowRecordListItemDTO]:
def get_many(
self,
page: int,
per_page: int,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
filter_text: Optional[str],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass

View File

@ -1,9 +1,11 @@
import datetime
from enum import Enum
from typing import Any, Union
import semver
from pydantic import BaseModel, Field, JsonValue, TypeAdapter, field_validator
from invokeai.app.util.metaenum import MetaEnum
from invokeai.app.util.misc import uuid_string
__workflow_meta_version__ = semver.Version.parse("1.0.0")
@ -57,8 +59,10 @@ WorkflowValidator = TypeAdapter(Workflow)
class WorkflowRecordDTO(BaseModel):
workflow_id: str = Field(description="The id of the workflow.")
workflow: Workflow = Field(description="The workflow.")
name: str = Field(description="The name of the workflow.")
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.")
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.")
opened_at: Union[datetime.datetime, str] = Field(description="The opened timestamp of the workflow.")
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowRecordDTO":
@ -75,6 +79,7 @@ class WorkflowRecordListItemDTO(BaseModel):
description: str = Field(description="The description of the workflow.")
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.")
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.")
opened_at: Union[datetime.datetime, str] = Field(description="The opened timestamp of the workflow.")
WorkflowRecordListItemDTOValidator = TypeAdapter(WorkflowRecordListItemDTO)
@ -82,3 +87,12 @@ WorkflowRecordListItemDTOValidator = TypeAdapter(WorkflowRecordListItemDTO)
class WorkflowNotFoundError(Exception):
"""Raised when a workflow is not found"""
class WorkflowRecordOrderBy(str, Enum, metaclass=MetaEnum):
"""The order by options for workflow records"""
CreatedAt = "created_at"
UpdatedAt = "updated_at"
OpenedAt = "opened_at"
Name = "name"

View File

@ -1,5 +1,8 @@
from typing import Optional
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
from invokeai.app.services.workflow_records.workflow_records_common import (
@ -8,6 +11,7 @@ from invokeai.app.services.workflow_records.workflow_records_common import (
WorkflowRecordDTO,
WorkflowRecordListItemDTO,
WorkflowRecordListItemDTOValidator,
WorkflowRecordOrderBy,
WorkflowValidator,
WorkflowWithoutID,
)
@ -25,11 +29,20 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self._invoker = invoker
def get(self, workflow_id: str) -> WorkflowRecordDTO:
"""Gets a workflow by ID. Updates the opened_at column."""
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT workflow_id, workflow, created_at, updated_at
UPDATE workflow_library
SET opened_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = ?;
""",
(workflow_id,),
)
self._cursor.execute(
"""--sql
SELECT workflow_id, workflow, name, created_at, updated_at, opened_at
FROM workflow_library
WHERE workflow_id = ?;
""",
@ -107,34 +120,50 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self._lock.release()
return None
def get_many(self, page: int, per_page: int) -> PaginatedResults[WorkflowRecordListItemDTO]:
def get_many(
self,
page: int,
per_page: int,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
filter_text: Optional[str] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
# sanitize!
assert order_by in WorkflowRecordOrderBy
assert direction in SQLiteDirection
count_query = "SELECT COUNT(*) FROM workflow_library"
main_query = """
SELECT
workflow_id,
json_extract(workflow, '$.name') AS name,
json_extract(workflow, '$.description') AS description,
name,
description,
created_at,
updated_at
updated_at,
opened_at
FROM workflow_library
ORDER BY name ASC
LIMIT ? OFFSET ?;
""",
(per_page, page * per_page),
)
"""
main_params = []
count_params = []
stripped_filter_name = filter_text.strip() if filter_text else None
if stripped_filter_name:
filter_string = "%" + stripped_filter_name + "%"
main_query += " WHERE name LIKE ? OR description LIKE ? "
count_query += " WHERE name LIKE ? OR description LIKE ?;"
main_params.extend([filter_string, filter_string])
count_params.extend([filter_string, filter_string])
main_query += f" ORDER BY {order_by.value} {direction.value} LIMIT ? OFFSET ?;"
main_params.extend([per_page, page * per_page])
self._cursor.execute(main_query, main_params)
rows = self._cursor.fetchall()
workflows = [WorkflowRecordListItemDTOValidator.validate_python(dict(row)) for row in rows]
self._cursor.execute(
"""--sql
SELECT COUNT(*)
FROM workflow_library;
"""
)
self._cursor.execute(count_query, count_params)
total = self._cursor.fetchone()[0]
pages = int(total / per_page) + 1
return PaginatedResults(
items=workflows,
page=page,
@ -154,10 +183,16 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self._cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS workflow_library (
workflow_id TEXT NOT NULL PRIMARY KEY, -- gets implicit index
workflow_id TEXT NOT NULL PRIMARY KEY,
workflow TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) -- updated via trigger
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated manually when retrieving workflow
opened_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Generated columns, needed for indexing and searching
name TEXT GENERATED ALWAYS as (json_extract(workflow, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(workflow, '$.description')) VIRTUAL NOT NULL
);
"""
)
@ -175,6 +210,32 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
"""
)
self._cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_workflow_library_created_at ON workflow_library(created_at);
"""
)
self._cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_workflow_library_updated_at ON workflow_library(updated_at);
"""
)
self._cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_workflow_library_opened_at ON workflow_library(opened_at);
"""
)
self._cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_workflow_library_name ON workflow_library(name);
"""
)
self._cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_workflow_library_description ON workflow_library(description);
"""
)
# We do not need the original `workflows` table or `workflow_images` junction table.
self._cursor.execute(
"""--sql

View File

@ -69,6 +69,7 @@
"data": "Data",
"delete": "Delete",
"details": "Details",
"direction": "Direction",
"ipAdapter": "IP Adapter",
"t2iAdapter": "T2I Adapter",
"darkMode": "Dark Mode",
@ -104,7 +105,6 @@
"langSpanish": "Español",
"languagePickerLabel": "Language",
"langUkranian": "Украї́нська",
"lastUpdated": "Last updated: {{date}}",
"lightMode": "Light Mode",
"linear": "Linear",
"load": "Load",
@ -117,6 +117,7 @@
"nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.",
"notInstalled": "Not $t(common.installed)",
"openInNewTab": "Open in New Tab",
"orderBy": "Order By",
"outpaint": "outpaint",
"outputs": "Outputs",
"postProcessDesc1": "Invoke AI offers a wide variety of post processing features. Image Upscaling and Face Restoration are already available in the WebUI. You can access them from the Advanced Options menu of the Text To Image and Image To Image tabs. You can also process images directly, using the image action buttons above the current image display or in the viewer.",
@ -164,6 +165,8 @@
"unifiedCanvas": "Unified Canvas",
"unknown": "Unknown",
"upload": "Upload",
"updated": "Updated",
"created": "Created",
"prevPage": "Previous Page",
"nextPage": "Next Page"
},
@ -1608,13 +1611,30 @@
"undo": "Undo"
},
"workflows": {
"workflows": "Workflows",
"workflowLibrary": "Workflow Library",
"userCategory": "User",
"systemCategory": "System",
"loadWorkflow": "$t(nodes.loadWorkflow)",
"user": "User",
"system": "System",
"recent": "Recent",
"userWorkflows": "$t(workflows.user) $t(workflows.workflows)",
"recentWorkflows": "$t(workflows.recent) $t(workflows.workflows)",
"systemWorkflows": "$t(workflows.system) $t(workflows.workflows)",
"openWorkflow": "Open Workflow",
"uploadWorkflow": "Upload Workflow",
"deleteWorkflow": "Delete Workflow",
"unnamedWorkflow": "Unnamed Workflow",
"downloadWorkflow": "Download Workflow",
"saveWorkflow": "Save Workflow"
"saveWorkflow": "Save Workflow",
"duplicateWorkflow": "Duplicate Workflow",
"problemSavingWorkflow": "Problem Saving Workflow",
"workflowSaved": "Workflow Saved",
"noRecentWorkflows": "No Recent Workflows",
"noUserWorkflows": "No User Workflows",
"noSystemWorkflows": "No System Workflows",
"problemLoading": "Problem Loading Workflows",
"loading": "Loading Workflows",
"noDescription": "No description",
"searchWorkflows": "Search Workflows",
"clearWorkflowSearchFilter": "Clear Workflow Search Filter"
}
}

View File

@ -1,4 +1,10 @@
import { FormControl, FormLabel, Tooltip, forwardRef } from '@chakra-ui/react';
import {
FormControl,
FormControlProps,
FormLabel,
Tooltip,
forwardRef,
} from '@chakra-ui/react';
import { Select, SelectProps } from '@mantine/core';
import { useMantineSelectStyles } from 'mantine-theme/hooks/useMantineSelectStyles';
import { RefObject, memo } from 'react';
@ -13,10 +19,19 @@ export type IAISelectProps = Omit<SelectProps, 'label'> & {
tooltip?: string | null;
inputRef?: RefObject<HTMLInputElement>;
label?: string;
formControlProps?: FormControlProps;
};
const IAIMantineSelect = forwardRef((props: IAISelectProps, ref) => {
const { tooltip, inputRef, label, disabled, required, ...rest } = props;
const {
tooltip,
formControlProps,
inputRef,
label,
disabled,
required,
...rest
} = props;
const styles = useMantineSelectStyles();
@ -28,6 +43,7 @@ const IAIMantineSelect = forwardRef((props: IAISelectProps, ref) => {
isDisabled={disabled}
position="static"
data-testid={`select-${label || props.placeholder}`}
{...formControlProps}
>
<FormLabel>{label}</FormLabel>
<Select disabled={disabled} ref={inputRef} styles={styles} {...rest} />

View File

@ -0,0 +1 @@
export const Nbsp = () => <>{'\u00A0'}</>;

View File

@ -1,40 +0,0 @@
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

@ -1,37 +0,0 @@
import { Flex } from '@chakra-ui/react';
import { memo, useState } from 'react';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import WorkflowLibraryCategories from './WorkflowLibraryCategories';
import WorkflowLibraryList from './WorkflowLibraryList';
import WorkflowLibraryPagination from './WorkflowLibraryPagination';
import { WorkflowCategory } from './types';
const PER_PAGE = 10;
const WorkflowLibraryContent = () => {
const [page, setPage] = useState(0);
const [category, setCategory] = useState<WorkflowCategory>('user');
const { data } = useListWorkflowsQuery({
page,
per_page: PER_PAGE,
});
if (!data) {
return null;
}
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

@ -1,25 +0,0 @@
import { Flex } from '@chakra-ui/react';
import WorkflowLibraryWorkflowItem from 'features/nodes/components/flow/WorkflowLibrary/WorkflowLibraryWorkflowItem';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import { memo } from 'react';
import { paths } from 'services/api/schema';
type Props = {
data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'];
};
const WorkflowLibraryList = ({ data }: Props) => {
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) => (
<WorkflowLibraryWorkflowItem key={w.workflow_id} workflowDTO={w} />
))}
</Flex>
</ScrollableContent>
</Flex>
);
};
export default memo(WorkflowLibraryList);

View File

@ -1,69 +0,0 @@
import { Flex, Heading, Spacer, Text } from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import dateFormat from 'dateformat';
import { useWorkflowLibraryContext } from 'features/nodes/components/flow/WorkflowLibrary/useWorkflowLibraryContext';
import { useDeleteLibraryWorkflow } from 'features/nodes/hooks/useDeleteLibraryWorkflow';
import { useGetAndLoadLibraryWorkflow } from 'features/nodes/hooks/useGetAndLoadLibraryWorkflow';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { paths } from 'services/api/schema';
type Props = {
workflowDTO: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json']['items'][number];
};
const WorkflowLibraryList = ({ workflowDTO }: Props) => {
const { t } = useTranslation();
const { onClose } = useWorkflowLibraryContext();
const { deleteWorkflow, deleteWorkflowResult } = useDeleteLibraryWorkflow({});
const { getAndLoadWorkflow, getAndLoadWorkflowResult } =
useGetAndLoadLibraryWorkflow({ onSuccess: onClose });
const handleDeleteWorkflow = useCallback(() => {
deleteWorkflow(workflowDTO.workflow_id);
}, [deleteWorkflow, workflowDTO.workflow_id]);
const handleGetAndLoadWorkflow = useCallback(() => {
getAndLoadWorkflow(workflowDTO.workflow_id);
}, [getAndLoadWorkflow, workflowDTO.workflow_id]);
return (
<Flex key={workflowDTO.workflow_id} w="full">
<Flex w="full" alignItems="center" gap={2}>
<Flex flexDir="column" flexGrow={1}>
<Flex alignItems="center" w="full">
<Heading size="sm">
{workflowDTO.name || t('workflows.unnamedWorkflow')}
</Heading>
<Spacer />
<Text fontSize="sm" fontStyle="italic" variant="subtext">
{t('common.lastUpdated', {
date: dateFormat(workflowDTO.updated_at),
})}
</Text>
</Flex>
<Text fontSize="sm" noOfLines={1}>
{workflowDTO.description}
</Text>
</Flex>
<IAIButton
onClick={handleGetAndLoadWorkflow}
isLoading={getAndLoadWorkflowResult.isLoading}
aria-label={t('workflows.loadWorkflow')}
>
{t('common.load')}
</IAIButton>
<IAIButton
colorScheme="error"
onClick={handleDeleteWorkflow}
isLoading={deleteWorkflowResult.isLoading}
aria-label={t('workflows.deleteWorkflow')}
>
{t('common.delete')}
</IAIButton>
</Flex>
</Flex>
);
};
export default memo(WorkflowLibraryList);

View File

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

View File

@ -1,12 +0,0 @@
import { WorkflowLibraryContext } from 'features/nodes/components/flow/WorkflowLibrary/context';
import { useContext } from 'react';
export const useWorkflowLibraryContext = () => {
const context = useContext(WorkflowLibraryContext);
if (!context) {
throw new Error(
'useWorkflowLibraryContext must be used within a WorkflowLibraryContext.Provider'
);
}
return context;
};

View File

@ -1,30 +0,0 @@
import { FileButton } from '@mantine/core';
import IAIIconButton from 'common/components/IAIIconButton';
import { useLoadWorkflowFromFile } from 'features/nodes/hooks/useLoadWorkflowFromFile';
import { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSave, FaUpload } from 'react-icons/fa';
const LoadWorkflowButton = () => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const loadWorkflowFromFile = useLoadWorkflowFromFile(resetRef);
return (
<FileButton
resetRef={resetRef}
accept="application/json"
onChange={loadWorkflowFromFile}
>
{(props) => (
<IAIIconButton
icon={<FaSave />}
tooltip={t('nodes.loadWorkflow')}
aria-label={t('nodes.loadWorkflow')}
{...props}
/>
)}
</FileButton>
);
};
export default memo(LoadWorkflowButton);

View File

@ -1,9 +1,10 @@
import { Flex } from '@chakra-ui/layout';
import { memo } from 'react';
import DownloadWorkflowButton from './DownloadWorkflowButton';
import LoadWorkflowButton from './LoadWorkflowButton';
import ResetWorkflowButton from './ResetWorkflowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopCenterPanel/SaveWorkflowButton';
import DownloadWorkflowButton from 'features/workflowLibrary/components/DownloadWorkflowButton';
import UploadWorkflowButton from 'features/workflowLibrary/components/LoadWorkflowFromFileButton';
import ResetWorkflowButton from 'features/workflowLibrary/components/ResetWorkflowButton';
import SaveWorkflowButton from 'features/workflowLibrary/components/SaveWorkflowButton';
import DuplicateWorkflowButton from 'features/workflowLibrary/components/DuplicateWorkflowButton';
const TopCenterPanel = () => {
return (
@ -17,8 +18,9 @@ const TopCenterPanel = () => {
}}
>
<DownloadWorkflowButton />
<LoadWorkflowButton />
<UploadWorkflowButton />
<SaveWorkflowButton />
<DuplicateWorkflowButton />
<ResetWorkflowButton />
</Flex>
);

View File

@ -1,53 +1,13 @@
import {
Flex,
Menu,
MenuButton,
MenuGroup,
MenuItem,
MenuList,
} from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import WorkflowLibraryButton from 'features/nodes/components/flow/WorkflowLibrary/WorkflowLibraryButton';
import { Flex } from '@chakra-ui/react';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import { memo } from 'react';
import { FaEllipsis } from 'react-icons/fa6';
import WorkflowEditorSettings from './WorkflowEditorSettings';
import { MENU_LIST_MOTION_PROPS as MENU_LIST_MOTION_PROPS } from 'theme/components/menu';
import { useTranslation } from 'react-i18next';
import { useDownloadWorkflow } from 'features/nodes/hooks/useDownloadWorkflow';
import { FaDownload, FaSave } from 'react-icons/fa';
import { useSaveWorkflow } from 'features/nodes/hooks/useSaveWorkflow';
const TopRightPanel = () => {
const { t } = useTranslation();
const downloadWorkflow = useDownloadWorkflow();
const saveWorkflow = useSaveWorkflow();
return (
<Flex sx={{ gap: 2, position: 'absolute', top: 2, insetInlineEnd: 2 }}>
<WorkflowEditorSettings />
<WorkflowLibraryButton />
<Menu>
<MenuButton as={IAIIconButton} icon={<FaEllipsis />} />
<MenuList motionProps={MENU_LIST_MOTION_PROPS}>
<MenuItem onClick={saveWorkflow} icon={<FaSave />}>
{t('workflows.saveWorkflow')}
</MenuItem>
<MenuItem onClick={downloadWorkflow} icon={<FaDownload />}>
{t('workflows.downloadWorkflow')}
</MenuItem>
{/* <MenuGroup title={t('common.settingsLabel')}>
<HotkeysModal>
<MenuItem as="button" icon={<FaKeyboard />}>
{t('common.hotkeysLabel')}
</MenuItem>
</HotkeysModal>
<SettingsModal>
<MenuItem as="button" icon={<FaCog />}>
{t('common.settingsLabel')}
</MenuItem>
</SettingsModal>
</MenuGroup> */}
</MenuList>
</Menu>
<WorkflowEditorSettings />
</Flex>
);
};

View File

@ -19,8 +19,8 @@ const DownloadWorkflowButton = () => {
return (
<IAIIconButton
icon={<FaDownload />}
tooltip={t('nodes.downloadWorkflow')}
aria-label={t('nodes.downloadWorkflow')}
tooltip={t('workflows.downloadWorkflow')}
aria-label={t('workflows.downloadWorkflow')}
onClick={handleDownload}
/>
);

View File

@ -0,0 +1,21 @@
import IAIIconButton from 'common/components/IAIIconButton';
import { useDuplicateLibraryWorkflow } from 'features/workflowLibrary/hooks/useDuplicateWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaClone } from 'react-icons/fa';
const DuplicateLibraryWorkflowButton = () => {
const { t } = useTranslation();
const { duplicateWorkflow, isLoading } = useDuplicateLibraryWorkflow();
return (
<IAIIconButton
icon={<FaClone />}
onClick={duplicateWorkflow}
isLoading={isLoading}
tooltip={t('workflows.duplicateWorkflow')}
aria-label={t('workflows.duplicateWorkflow')}
/>
);
};
export default memo(DuplicateLibraryWorkflowButton);

View File

@ -1,11 +1,11 @@
import { FileButton } from '@mantine/core';
import IAIIconButton from 'common/components/IAIIconButton';
import { useLoadWorkflowFromFile } from 'features/nodes/hooks/useLoadWorkflowFromFile';
import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
import { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa';
const LoadWorkflowButton = () => {
const UploadWorkflowButton = () => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const loadWorkflowFromFile = useLoadWorkflowFromFile(resetRef);
@ -18,8 +18,8 @@ const LoadWorkflowButton = () => {
{(props) => (
<IAIIconButton
icon={<FaUpload />}
tooltip={t('nodes.loadWorkflow')}
aria-label={t('nodes.loadWorkflow')}
tooltip={t('workflows.uploadWorkflow')}
aria-label={t('workflows.uploadWorkflow')}
{...props}
/>
)}
@ -27,4 +27,4 @@ const LoadWorkflowButton = () => {
);
};
export default memo(LoadWorkflowButton);
export default memo(UploadWorkflowButton);

View File

@ -10,8 +10,7 @@ import {
Text,
useDisclosure,
} from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { addToast } from 'features/system/store/systemSlice';
@ -26,10 +25,6 @@ const ResetWorkflowButton = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<HTMLButtonElement | null>(null);
const nodesCount = useAppSelector(
(state: RootState) => state.nodes.nodes.length
);
const handleConfirmClear = useCallback(() => {
dispatch(nodeEditorReset());
@ -52,7 +47,6 @@ const ResetWorkflowButton = () => {
tooltip={t('nodes.resetWorkflow')}
aria-label={t('nodes.resetWorkflow')}
onClick={onOpen}
isDisabled={!nodesCount}
colorScheme="error"
/>

View File

@ -0,0 +1,21 @@
import IAIIconButton from 'common/components/IAIIconButton';
import { useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSave } from 'react-icons/fa';
const SaveLibraryWorkflowButton = () => {
const { t } = useTranslation();
const { saveWorkflow, isLoading } = useSaveLibraryWorkflow();
return (
<IAIIconButton
icon={<FaSave />}
onClick={saveWorkflow}
isLoading={isLoading}
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
/>
);
};
export default memo(SaveLibraryWorkflowButton);

View File

@ -1,17 +1,17 @@
import { useDisclosure } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { WorkflowLibraryContext } from 'features/nodes/components/flow/WorkflowLibrary/context';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaFolderOpen } from 'react-icons/fa';
import WorkflowLibraryModal from './WorkflowLibraryModal';
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
const WorkflowLibraryButton = () => {
const { t } = useTranslation();
const disclosure = useDisclosure();
return (
<WorkflowLibraryContext.Provider value={disclosure}>
<WorkflowLibraryModalContext.Provider value={disclosure}>
<IAIIconButton
icon={<FaFolderOpen />}
onClick={disclosure.onOpen}
@ -19,7 +19,7 @@ const WorkflowLibraryButton = () => {
aria-label={t('workflows.workflowLibrary')}
/>
<WorkflowLibraryModal />
</WorkflowLibraryContext.Provider>
</WorkflowLibraryModalContext.Provider>
);
};

View File

@ -0,0 +1,34 @@
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import WorkflowLibraryListWrapper from 'features/workflowLibrary/components/WorkflowLibraryListWrapper';
import WorkflowLibrarySystemList from 'features/workflowLibrary/components/WorkflowLibrarySystemList';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import WorkflowLibraryUserList from './WorkflowLibraryUserList';
const WorkflowLibraryContent = () => {
const { t } = useTranslation();
return (
<Tabs w="full" h="full" isLazy>
<TabList w="10rem" layerStyle="second" borderRadius="base" p={2}>
<Tab>{t('workflows.user')}</Tab>
<Tab>{t('workflows.system')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<WorkflowLibraryListWrapper>
<WorkflowLibraryUserList />
</WorkflowLibraryListWrapper>
</TabPanel>
<TabPanel>
<WorkflowLibraryListWrapper>
<WorkflowLibrarySystemList />
</WorkflowLibraryListWrapper>
</TabPanel>
</TabPanels>
</Tabs>
);
};
export default memo(WorkflowLibraryContent);

View File

@ -0,0 +1,88 @@
import { Flex, Heading, Spacer, Text } from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import dateFormat, { masks } from 'dateformat';
import { useDeleteLibraryWorkflow } from 'features/workflowLibrary/hooks/useDeleteLibraryWorkflow';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { useWorkflowLibraryModalContext } from 'features/workflowLibrary/context/useWorkflowLibraryModalContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { WorkflowRecordListItemDTO } from 'services/api/types';
type Props = {
workflowDTO: WorkflowRecordListItemDTO;
};
const WorkflowLibraryListItem = ({ workflowDTO }: Props) => {
const { t } = useTranslation();
const { onClose } = useWorkflowLibraryModalContext();
const { deleteWorkflow, deleteWorkflowResult } = useDeleteLibraryWorkflow({});
const { getAndLoadWorkflow, getAndLoadWorkflowResult } =
useGetAndLoadLibraryWorkflow({ onSuccess: onClose });
const handleDeleteWorkflow = useCallback(() => {
deleteWorkflow(workflowDTO.workflow_id);
}, [deleteWorkflow, workflowDTO.workflow_id]);
const handleGetAndLoadWorkflow = useCallback(() => {
getAndLoadWorkflow(workflowDTO.workflow_id);
}, [getAndLoadWorkflow, workflowDTO.workflow_id]);
return (
<Flex key={workflowDTO.workflow_id} w="full">
<Flex w="full" alignItems="center" gap={2}>
<Flex flexDir="column" flexGrow={1}>
<Flex alignItems="center" w="full">
<Heading size="sm">
{workflowDTO.name || t('workflows.unnamedWorkflow')}
</Heading>
<Spacer />
<Text fontSize="sm" variant="subtext">
{t('common.updated')}:{' '}
{dateFormat(workflowDTO.updated_at, masks.shortDate)}{' '}
{dateFormat(workflowDTO.updated_at, masks.shortTime)}
</Text>
</Flex>
<Flex alignItems="center" w="full">
{workflowDTO.description ? (
<Text fontSize="sm" noOfLines={1}>
{workflowDTO.description}
</Text>
) : (
<Text
fontSize="sm"
variant="subtext"
fontStyle="italic"
noOfLines={1}
>
{t('workflows.noDescription')}
</Text>
)}
<Spacer />
<Text fontSize="sm" variant="subtext">
{t('common.created')}:{' '}
{dateFormat(workflowDTO.created_at, masks.shortDate)}{' '}
{dateFormat(workflowDTO.created_at, masks.shortTime)}
</Text>
</Flex>
</Flex>
<IAIButton
onClick={handleGetAndLoadWorkflow}
isLoading={getAndLoadWorkflowResult.isLoading}
aria-label={t('workflows.openWorkflow')}
>
{t('common.load')}
</IAIButton>
<IAIButton
colorScheme="error"
onClick={handleDeleteWorkflow}
isLoading={deleteWorkflowResult.isLoading}
aria-label={t('workflows.deleteWorkflow')}
>
{t('common.delete')}
</IAIButton>
</Flex>
</Flex>
);
};
export default memo(WorkflowLibraryListItem);

View File

@ -0,0 +1,21 @@
import { Flex } from '@chakra-ui/react';
import { PropsWithChildren, memo } from 'react';
const WorkflowLibraryListWrapper = (props: PropsWithChildren) => {
return (
<Flex
w="full"
h="full"
flexDir="column"
layerStyle="second"
py={2}
px={4}
gap={2}
borderRadius="base"
>
{props.children}
</Flex>
);
};
export default memo(WorkflowLibraryListWrapper);

View File

@ -7,14 +7,14 @@ import {
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import WorkflowLibraryContent from 'features/nodes/components/flow/WorkflowLibrary/WorkflowLibraryContent';
import { useWorkflowLibraryContext } from 'features/nodes/components/flow/WorkflowLibrary/useWorkflowLibraryContext';
import WorkflowLibraryContent from 'features/workflowLibrary/components/WorkflowLibraryContent';
import { useWorkflowLibraryModalContext } from 'features/workflowLibrary/context/useWorkflowLibraryModalContext';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const WorkflowLibraryModal = () => {
const { t } = useTranslation();
const { isOpen, onClose } = useWorkflowLibraryContext();
const { isOpen, onClose } = useWorkflowLibraryModalContext();
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<ModalOverlay />

View File

@ -52,10 +52,6 @@ const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => {
return pages;
}, [data.pages, page, setPage]);
if (data.items.length === 0) {
return null;
}
return (
<ButtonGroup>
<IAIIconButton
@ -68,6 +64,7 @@ const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => {
{pages.map((p) => (
<IAIButton
w={10}
isDisabled={data.pages === 1}
onClick={p.page === page ? undefined : p.onClick}
variant={p.page === page ? 'invokeAI' : 'ghost'}
key={p.page}

View File

@ -0,0 +1,46 @@
import { Divider, Flex, Heading, Spacer } from '@chakra-ui/react';
import {
IAINoContentFallback,
IAINoContentFallbackWithSpinner,
} from 'common/components/IAIImageFallback';
import WorkflowLibraryListItem from 'features/workflowLibrary/components/WorkflowLibraryListItem';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useListRecentWorkflowsQuery } from 'services/api/endpoints/workflows';
const WorkflowLibraryRecentList = () => {
const { t } = useTranslation();
const { data, isLoading, isError } = useListRecentWorkflowsQuery();
if (isLoading) {
return <IAINoContentFallbackWithSpinner label={t('workflows.loading')} />;
}
if (!data || isError) {
return <IAINoContentFallback label={t('workflows.problemLoading')} />;
}
if (!data.items.length) {
return <IAINoContentFallback label={t('workflows.noRecentWorkflows')} />;
}
return (
<>
<Flex gap={4} alignItems="center" h={10} flexShrink={0} flexGrow={0}>
<Heading size="md">{t('workflows.recentWorkflows')}</Heading>
<Spacer />
</Flex>
<Divider />
<ScrollableContent>
<Flex w="full" h="full" gap={2} flexDir="column">
{data.items.map((w) => (
<WorkflowLibraryListItem key={w.workflow_id} workflowDTO={w} />
))}
</Flex>
</ScrollableContent>
</>
);
};
export default memo(WorkflowLibraryRecentList);

View File

@ -0,0 +1,117 @@
import { Divider, Flex, Heading, Spacer } from '@chakra-ui/react';
import { SelectItem } from '@mantine/core';
import {
IAINoContentFallback,
IAINoContentFallbackWithSpinner,
} from 'common/components/IAIImageFallback';
import IAIMantineSelect from 'common/components/IAIMantineSelect';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import WorkflowLibraryListItem from 'features/workflowLibrary/components/WorkflowLibraryListItem';
import WorkflowLibraryPagination from 'features/workflowLibrary/components/WorkflowLibraryPagination';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListSystemWorkflowsQuery } from 'services/api/endpoints/workflows';
import { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
const ORDER_BY_DATA: SelectItem[] = [
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
{ value: 'name', label: 'Name' },
];
const DIRECTION_DATA: SelectItem[] = [
{ value: 'ASC', label: 'Ascending' },
{ value: 'DESC', label: 'Descending' },
];
const WorkflowLibraryList = () => {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [order_by, setOrderBy] = useState<WorkflowRecordOrderBy>('created_at');
const [direction, setDirection] = useState<SQLiteDirection>('ASC');
const { data, isLoading, isError, isFetching } =
useListSystemWorkflowsQuery();
const handleChangeOrderBy = useCallback(
(value: string | null) => {
if (!value || value === order_by) {
return;
}
setOrderBy(value as WorkflowRecordOrderBy);
setPage(0);
},
[order_by]
);
const handleChangeDirection = useCallback(
(value: string | null) => {
if (!value || value === direction) {
return;
}
setDirection(value as SQLiteDirection);
setPage(0);
},
[direction]
);
if (isLoading) {
return <IAINoContentFallbackWithSpinner label={t('workflows.loading')} />;
}
if (!data || isError) {
return <IAINoContentFallback label={t('workflows.problemLoading')} />;
}
if (!data.items.length) {
return <IAINoContentFallback label={t('workflows.noSystemWorkflows')} />;
}
return (
<>
<Flex gap={4} alignItems="center" h={10} flexShrink={0} flexGrow={0}>
<Heading size="md">{t('workflows.systemWorkflows')}</Heading>
<Spacer />
<IAIMantineSelect
label={t('common.orderBy')}
value={order_by}
data={ORDER_BY_DATA}
onChange={handleChangeOrderBy}
formControlProps={{
w: '12rem',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
disabled={isFetching}
/>
<IAIMantineSelect
label={t('common.direction')}
value={direction}
data={DIRECTION_DATA}
onChange={handleChangeDirection}
formControlProps={{
w: '12rem',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
disabled={isFetching}
/>
</Flex>
<Divider />
<ScrollableContent>
<Flex w="full" h="full" gap={2} flexDir="column">
{data.items.map((w) => (
<WorkflowLibraryListItem key={w.workflow_id} workflowDTO={w} />
))}
</Flex>
</ScrollableContent>
<Divider />
<Flex w="full" justifyContent="space-around">
<WorkflowLibraryPagination data={data} page={page} setPage={setPage} />
</Flex>
</>
);
};
export default memo(WorkflowLibraryList);

View File

@ -0,0 +1,186 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
Divider,
Flex,
Heading,
IconButton,
Input,
InputGroup,
InputRightElement,
Spacer,
} from '@chakra-ui/react';
import { SelectItem } from '@mantine/core';
import {
IAINoContentFallback,
IAINoContentFallbackWithSpinner,
} from 'common/components/IAIImageFallback';
import IAIMantineSelect from 'common/components/IAIMantineSelect';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import WorkflowLibraryListItem from 'features/workflowLibrary/components/WorkflowLibraryListItem';
import WorkflowLibraryPagination from 'features/workflowLibrary/components/WorkflowLibraryPagination';
import { ChangeEvent, KeyboardEvent, memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
import { useDebounce } from 'use-debounce';
const PER_PAGE = 10;
const ORDER_BY_DATA: SelectItem[] = [
{ value: 'opened_at', label: 'Recently Opened' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
{ value: 'name', label: 'Name' },
];
const DIRECTION_DATA: SelectItem[] = [
{ value: 'ASC', label: 'Ascending' },
{ value: 'DESC', label: 'Descending' },
];
const WorkflowLibraryList = () => {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [filter_text, setFilterText] = useState('');
const [order_by, setOrderBy] = useState<WorkflowRecordOrderBy>('opened_at');
const [direction, setDirection] = useState<SQLiteDirection>('ASC');
const [debouncedFilterText] = useDebounce(filter_text, 500, {
leading: true,
});
const { data, isLoading, isError, isFetching } = useListWorkflowsQuery({
page,
per_page: PER_PAGE,
order_by,
direction,
filter_name: debouncedFilterText,
});
const handleChangeOrderBy = useCallback(
(value: string | null) => {
if (!value || value === order_by) {
return;
}
setOrderBy(value as WorkflowRecordOrderBy);
setPage(0);
},
[order_by]
);
const handleChangeDirection = useCallback(
(value: string | null) => {
if (!value || value === direction) {
return;
}
setDirection(value as SQLiteDirection);
setPage(0);
},
[direction]
);
const resetFilterText = useCallback(() => {
setFilterText('');
setPage(0);
}, []);
const handleKeydownFilterText = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
// exit search mode on escape
if (e.key === 'Escape') {
resetFilterText();
e.preventDefault();
setPage(0);
}
},
[resetFilterText]
);
const handleChangeFilterText = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setFilterText(e.target.value);
setPage(0);
},
[]
);
if (isLoading) {
return <IAINoContentFallbackWithSpinner label={t('workflows.loading')} />;
}
if (!data || isError) {
return <IAINoContentFallback label={t('workflows.problemLoading')} />;
}
return (
<>
<Flex gap={4} alignItems="center" h={10} flexShrink={0} flexGrow={0}>
<Heading size="md">{t('workflows.userWorkflows')}</Heading>
<Spacer />
<InputGroup w="20rem">
<Input
placeholder={t('workflows.searchWorkflows')}
value={filter_text}
onKeyDown={handleKeydownFilterText}
onChange={handleChangeFilterText}
data-testid="workflow-search-input"
/>
{filter_text.trim().length && (
<InputRightElement>
<IconButton
onClick={resetFilterText}
size="xs"
variant="ghost"
aria-label={t('workflows.clearWorkflowSearchFilter')}
opacity={0.5}
icon={<CloseIcon boxSize={2} />}
/>
</InputRightElement>
)}
</InputGroup>
<IAIMantineSelect
label={t('common.orderBy')}
value={order_by}
data={ORDER_BY_DATA}
onChange={handleChangeOrderBy}
formControlProps={{
w: '15rem',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
disabled={isFetching}
/>
<IAIMantineSelect
label={t('common.direction')}
value={direction}
data={DIRECTION_DATA}
onChange={handleChangeDirection}
formControlProps={{
w: '12rem',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
disabled={isFetching}
/>
</Flex>
<Divider />
{data.items.length ? (
<ScrollableContent>
<Flex w="full" h="full" gap={2} flexDir="column">
{data.items.map((w) => (
<WorkflowLibraryListItem key={w.workflow_id} workflowDTO={w} />
))}
</Flex>
</ScrollableContent>
) : (
<IAINoContentFallback label={t('workflows.noUserWorkflows')} />
)}
<Divider />
<Flex w="full" justifyContent="space-around">
<WorkflowLibraryPagination data={data} page={page} setPage={setPage} />
</Flex>
</>
);
};
export default memo(WorkflowLibraryList);

View File

@ -1,6 +1,5 @@
import { UseDisclosureReturn } from '@chakra-ui/react';
import { createContext } from 'react';
export const WorkflowLibraryContext = createContext<UseDisclosureReturn | null>(
null
);
export const WorkflowLibraryModalContext =
createContext<UseDisclosureReturn | null>(null);

View File

@ -0,0 +1,12 @@
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
import { useContext } from 'react';
export const useWorkflowLibraryModalContext = () => {
const context = useContext(WorkflowLibraryModalContext);
if (!context) {
throw new Error(
'useWorkflowLibraryContext must be used within a WorkflowLibraryContext.Provider'
);
}
return context;
};

View File

@ -0,0 +1,38 @@
import { useAppToaster } from 'app/components/Toaster';
import { useAppDispatch } from 'app/store/storeHooks';
import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { zWorkflowV2 } from 'features/nodes/types/workflow';
import { omit } from 'lodash-es';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateWorkflowMutation } from 'services/api/endpoints/workflows';
export const useDuplicateLibraryWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const workflow = useWorkflow();
const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation();
const toaster = useAppToaster();
const duplicateWorkflow = useCallback(async () => {
try {
const data = await createWorkflow(omit(workflow, 'id')).unwrap();
const createdWorkflow = zWorkflowV2.parse(data.workflow);
dispatch(workflowLoaded(createdWorkflow));
toaster({
title: t('workflows.workflowSaved'),
status: 'success',
});
} catch (e) {
toaster({
title: t('workflows.problemSavingWorkflow'),
status: 'error',
});
}
}, [workflow, dispatch, toaster, t, createWorkflow]);
return {
duplicateWorkflow,
isLoading: createWorkflowResult.isLoading,
isError: createWorkflowResult.isError,
};
};

View File

@ -4,12 +4,14 @@ import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { zWorkflowV2 } from 'features/nodes/types/workflow';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
useCreateWorkflowMutation,
useUpdateWorkflowMutation,
} from 'services/api/endpoints/workflows';
export const useSaveWorkflow = () => {
export const useSaveLibraryWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const workflow = useWorkflow();
const [updateWorkflow, updateWorkflowResult] = useUpdateWorkflowMutation();
@ -18,29 +20,32 @@ export const useSaveWorkflow = () => {
const saveWorkflow = useCallback(async () => {
try {
if (workflow.id) {
console.log('update workflow');
const data = await updateWorkflow(workflow).unwrap();
const updatedWorkflow = zWorkflowV2.parse(data.workflow);
dispatch(workflowLoaded(updatedWorkflow));
toaster({
title: t('workflows.workflowSaved'),
status: 'success',
});
} else {
console.log('create workflow');
const data = await createWorkflow(workflow).unwrap();
const createdWorkflow = zWorkflowV2.parse(data.workflow);
dispatch(workflowLoaded(createdWorkflow));
}
toaster({
title: 'Workflow saved',
title: t('workflows.workflowSaved'),
status: 'success',
duration: 3000,
});
}
} catch (e) {
toaster({
title: 'Failed to save workflow',
// description: e.message,
title: t('workflows.problemSavingWorkflow'),
status: 'error',
duration: 3000,
});
}
}, [workflow, toaster, updateWorkflow, dispatch, createWorkflow]);
return saveWorkflow;
}, [workflow, updateWorkflow, dispatch, toaster, t, createWorkflow]);
return {
saveWorkflow,
isLoading: updateWorkflowResult.isLoading || createWorkflowResult.isLoading,
isError: updateWorkflowResult.isError || createWorkflowResult.isError,
};
};

View File

@ -1,4 +1,3 @@
import { WorkflowV2 } from 'features/nodes/types/workflow';
import { paths } from 'services/api/schema';
import { LIST_TAG, api } from '..';
@ -12,6 +11,19 @@ export const workflowsApi = api.injectEndpoints({
providesTags: (result, error, workflow_id) => [
{ type: 'Workflow', id: workflow_id },
],
onQueryStarted: async (arg, api) => {
const { dispatch, queryFulfilled } = api;
try {
await queryFulfilled;
dispatch(
workflowsApi.util.invalidateTags([
{ type: 'WorkflowsRecent', id: LIST_TAG },
])
);
} catch {
// no-op
}
},
}),
deleteWorkflow: build.mutation<void, string>({
query: (workflow_id) => ({
@ -21,6 +33,7 @@ export const workflowsApi = api.injectEndpoints({
invalidatesTags: (result, error, workflow_id) => [
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow_id },
{ type: 'WorkflowsRecent', id: LIST_TAG },
],
}),
createWorkflow: build.mutation<
@ -28,11 +41,14 @@ export const workflowsApi = api.injectEndpoints({
paths['/api/v1/workflows/']['post']['requestBody']['content']['application/json']['workflow']
>({
query: (workflow) => ({
url: 'workflows',
url: 'workflows/',
method: 'POST',
body: workflow,
body: { workflow },
}),
invalidatesTags: [{ type: 'Workflow', id: LIST_TAG }],
invalidatesTags: [
{ type: 'Workflow', id: LIST_TAG },
{ type: 'WorkflowsRecent', id: LIST_TAG },
],
}),
updateWorkflow: build.mutation<
paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'],
@ -41,9 +57,10 @@ export const workflowsApi = api.injectEndpoints({
query: (workflow) => ({
url: `workflows/i/${workflow.id}`,
method: 'PATCH',
body: workflow,
body: { workflow },
}),
invalidatesTags: (response, error, workflow) => [
{ type: 'WorkflowsRecent', id: LIST_TAG },
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow.id },
],
@ -58,13 +75,55 @@ export const workflowsApi = api.injectEndpoints({
}),
providesTags: [{ type: 'Workflow', id: LIST_TAG }],
}),
listRecentWorkflows: build.query<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
void
>({
query: () => ({
url: 'workflows/',
params: {
page: 0,
per_page: 10,
order_by: 'opened_at',
direction: 'DESC',
},
}),
providesTags: [{ type: 'WorkflowsRecent', id: LIST_TAG }],
}),
listSystemWorkflows: build.query<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
void
>({
query: () => ({
url: 'workflows/',
params: {
page: 0,
per_page: 10,
order_by: 'opened_at',
direction: 'DESC',
},
}),
transformResponse: () => {
return {
page: 0,
per_page: 10,
items: [],
total: 0,
pages: 0,
};
},
providesTags: [{ type: 'WorkflowsRecent', id: LIST_TAG }],
}),
}),
});
export const {
useGetWorkflowQuery,
useLazyGetWorkflowQuery,
useCreateWorkflowMutation,
useDeleteWorkflowMutation,
useUpdateWorkflowMutation,
useListWorkflowsQuery,
useListRecentWorkflowsQuery,
useListSystemWorkflowsQuery,
} = workflowsApi;

View File

@ -41,6 +41,7 @@ export const tagTypes = [
'LoRAModel',
'SDXLRefinerModel',
'Workflow',
'WorkflowsRecent',
] as const;
export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>;
export const LIST_TAG = 'LIST';

File diff suppressed because one or more lines are too long

View File

@ -114,6 +114,10 @@ export type GraphExecutionState = s['GraphExecutionState'];
export type Batch = s['Batch'];
export type SessionQueueItemDTO = s['SessionQueueItemDTO'];
export type SessionQueueItem = s['SessionQueueItem'];
export type WorkflowRecordOrderBy = s['WorkflowRecordOrderBy'];
export type SQLiteDirection = s['SQLiteDirection'];
export type WorkflowDTO = s['WorkflowRecordDTO'];
export type WorkflowRecordListItemDTO = s['WorkflowRecordListItemDTO'];
// General nodes
export type CollectInvocation = s['CollectInvocation'];