feat: simplify default workflows

- Rename "system" -> "default"
- Simplify syncing logic
- Update UI to match
This commit is contained in:
psychedelicious 2023-12-02 11:41:05 +11:00
parent 224438a108
commit 4fd163698c
16 changed files with 161 additions and 389 deletions

View File

@ -31,7 +31,6 @@ from ..services.shared.default_graphs import create_system_graphs
from ..services.shared.graph import GraphExecutionState, LibraryGraph
from ..services.shared.sqlite.sqlite_database import SqliteDatabase
from ..services.urls.urls_default import LocalUrlService
from ..services.workflow_records.sync_system_workflows import sync_system_workflows
from ..services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from .events import FastAPIEventService
@ -124,7 +123,6 @@ class ApiDependencies:
)
create_system_graphs(services.graph_library)
sync_system_workflows(workflow_records=services.workflow_records, logger=logger)
db.clean()
ApiDependencies.invoker = Invoker(services)

View File

@ -1,5 +1,4 @@
{
"id": "af7030e2-c64f-4f03-b387-9634bb54ae5f",
"name": "Text to Image - SD1.5",
"author": "InvokeAI",
"description": "Sample text to image workflow for Stable Diffusion 1.5/2",
@ -30,7 +29,7 @@
}
],
"meta": {
"category": "system",
"category": "default",
"version": "2.0.0"
},
"nodes": [
@ -394,7 +393,7 @@
"notes": "",
"isIntermediate": true,
"useCache": true,
"version": "1.4.0",
"version": "1.5.0",
"nodePack": "invokeai",
"inputs": {
"positive_conditioning": {
@ -534,6 +533,18 @@
"name": "T2IAdapterField"
}
},
"cfg_rescale_multiplier": {
"id": "9101f0a6-5fe0-4826-b7b3-47e5d506826c",
"name": "cfg_rescale_multiplier",
"fieldKind": "input",
"label": "",
"type": {
"isCollection": false,
"isCollectionOrScalar": false,
"name": "FloatField"
},
"value": 0
},
"latents": {
"id": "334d4ba3-5a99-4195-82c5-86fb3f4f7d43",
"name": "latents",
@ -591,7 +602,7 @@
}
},
"width": 320,
"height": 646,
"height": 703,
"position": {
"x": 1400,
"y": 25

View File

@ -1,56 +0,0 @@
import pkgutil
from logging import Logger
from pathlib import Path
import semver
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
from invokeai.app.services.workflow_records.workflow_records_common import (
Workflow,
WorkflowValidator,
)
# TODO: When I remove a workflow from system_workflows/ and do a `pip install --upgrade .`, the file
# is not removed from site-packages! The logic to delete old system workflows below doesn't work
# for normal installs. It does work for editable. Not sure why.
system_workflows_dir = "system_workflows"
def get_system_workflows_from_json() -> list[Workflow]:
app_workflows: list[Workflow] = []
workflow_paths = (Path(__file__).parent / Path(system_workflows_dir)).glob("*.json")
for workflow_path in workflow_paths:
workflow_bytes = pkgutil.get_data(__name__, f"{system_workflows_dir}/{workflow_path.name}")
if workflow_bytes is None:
raise ValueError(f"Could not load system workflow: {workflow_path.name}")
app_workflows.append(WorkflowValidator.validate_json(workflow_bytes))
return app_workflows
def sync_system_workflows(workflow_records: WorkflowRecordsStorageBase, logger: Logger) -> None:
"""Syncs system workflows in the workflow_library database with the latest system workflows."""
system_workflows = get_system_workflows_from_json()
system_workflow_ids = [w.id for w in system_workflows]
installed_workflows = workflow_records._get_all_system_workflows()
installed_workflow_ids = [w.id for w in installed_workflows]
for workflow in installed_workflows:
if workflow.id not in system_workflow_ids:
workflow_records._delete_system_workflow(workflow.id)
logger.info(f"Deleted system workflow: {workflow.name}")
for workflow in system_workflows:
if workflow.id not in installed_workflow_ids:
workflow_records._create_system_workflow(workflow)
logger.info(f"Installed system workflow: {workflow.name}")
else:
installed_workflow = workflow_records.get(workflow.id).workflow
installed_version = semver.Version.parse(installed_workflow.version)
new_version = semver.Version.parse(workflow.version)
if new_version.compare(installed_version) > 0:
workflow_records._update_system_workflow(workflow)
logger.info(f"Updated system workflow: {workflow.name}")

View File

@ -48,23 +48,3 @@ class WorkflowRecordsStorageBase(ABC):
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
@abstractmethod
def _create_system_workflow(self, workflow: Workflow) -> None:
"""Creates a system workflow. Internal use only."""
pass
@abstractmethod
def _update_system_workflow(self, workflow: Workflow) -> None:
"""Updates a system workflow. Internal use only."""
pass
@abstractmethod
def _delete_system_workflow(self, workflow_id: str) -> None:
"""Deletes a system workflow. Internal use only."""
pass
@abstractmethod
def _get_all_system_workflows(self) -> list[Workflow]:
"""Gets all system workflows. Internal use only."""
pass

View File

@ -31,12 +31,12 @@ class WorkflowRecordOrderBy(str, Enum, metaclass=MetaEnum):
class WorkflowCategory(str, Enum, metaclass=MetaEnum):
User = "user"
System = "system"
Default = "default"
class WorkflowMeta(BaseModel):
version: str = Field(description="The version of the workflow schema.")
category: WorkflowCategory = Field(description="The category of the workflow (user or system).")
category: WorkflowCategory = Field(description="The category of the workflow (user or default).")
@field_validator("version")
def validate_version(cls, version: str):

View File

@ -1,3 +1,4 @@
from pathlib import Path
from typing import Optional
from invokeai.app.services.invoker import Invoker
@ -28,6 +29,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
def start(self, invoker: Invoker) -> None:
self._invoker = invoker
self._sync_default_workflows()
def get(self, workflow_id: str) -> WorkflowRecordDTO:
"""Gets a workflow by ID. Updates the opened_at column."""
@ -182,76 +184,48 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
finally:
self._lock.release()
def _create_system_workflow(self, workflow: Workflow) -> None:
try:
self._lock.acquire()
# Only system workflows may be managed by this method
assert workflow.meta.category is WorkflowCategory.System
self._cursor.execute(
"""--sql
INSERT OR REPLACE INTO workflow_library (
workflow_id,
workflow
)
VALUES (?, ?);
""",
(workflow.id, workflow.model_dump_json()),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
def _sync_default_workflows(self) -> None:
"""Syncs default workflows to the database. Internal use only."""
def _update_system_workflow(self, workflow: Workflow) -> None:
try:
self._lock.acquire()
# Only system workflows may be managed by this method
assert workflow.meta.category is WorkflowCategory.System
self._cursor.execute(
"""--sql
UPDATE workflow_library
SET workflow = ?
WHERE workflow_id = ? AND category = 'system';
""",
(workflow.model_dump_json(), workflow.id),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
"""
An enhancment might be to only update workflows that have changed. This would require we
ensure workflow IDs don't change, and the workflow version is incremented.
It's much simpler to just replace them all with whichever workflows are in the directory.
The downside is that the `updated_at` and `opened_at` timestamps for default workflows are
meaningless, as they are overwritten every time the server starts.
"""
def _delete_system_workflow(self, workflow_id: str) -> None:
try:
self._lock.acquire()
workflows: list[Workflow] = []
workflows_dir = Path(__file__).parent / Path("default_workflows")
workflow_paths = workflows_dir.glob("*.json")
for path in workflow_paths:
bytes_ = path.read_bytes()
workflow = WorkflowValidator.validate_json(bytes_)
workflows.append(workflow)
# Only default workflows may be managed by this method
assert all(w.meta.category is WorkflowCategory.Default for w in workflows)
self._cursor.execute(
"""--sql
DELETE FROM workflow_library
WHERE workflow_id = ? AND category = 'system';
""",
(workflow_id,),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
def _get_all_system_workflows(self) -> list[Workflow]:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT workflow FROM workflow_library
WHERE category = 'system';
WHERE category = 'default';
"""
)
rows = self._cursor.fetchall()
return [WorkflowValidator.validate_json(dict(row)["workflow"]) for row in rows]
for w in workflows:
self._cursor.execute(
"""--sql
INSERT OR REPLACE INTO workflow_library (
workflow_id,
workflow
)
VALUES (?, ?);
""",
(w.id, w.model_dump_json()),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise

View File

@ -1626,12 +1626,8 @@
"workflows": {
"workflows": "Workflows",
"workflowLibrary": "Workflow Library",
"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)",
"userWorkflows": "My Workflows",
"defaultWorkflows": "Default Workflows",
"openWorkflow": "Open Workflow",
"uploadWorkflow": "Upload Workflow",
"deleteWorkflow": "Delete Workflow",

View File

@ -14,7 +14,7 @@ export type XYPosition = z.infer<typeof zXYPosition>;
export const zDimension = z.number().gt(0).nullish();
export type Dimension = z.infer<typeof zDimension>;
export const zWorkflowCategory = z.enum(['user', 'system']);
export const zWorkflowCategory = z.enum(['user', 'default']);
export type WorkflowCategory = z.infer<typeof zWorkflowCategory>;
// #endregion

View File

@ -41,7 +41,7 @@ export const validateWorkflow = (
// System workflows are only allowed to be used as templates.
// If a system workflow is loaded, change its category to user and remove its ID so that we can save it as a user workflow.
if (_workflow.meta.category === 'system') {
if (_workflow.meta.category === 'default') {
_workflow.meta.category = 'user';
_workflow.id = undefined;
}

View File

@ -20,7 +20,14 @@ import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableCon
import { WorkflowCategory } from 'features/nodes/types/workflow';
import WorkflowLibraryListItem from 'features/workflowLibrary/components/WorkflowLibraryListItem';
import WorkflowLibraryPagination from 'features/workflowLibrary/components/WorkflowLibraryPagination';
import { ChangeEvent, KeyboardEvent, memo, useCallback, useState } from 'react';
import {
ChangeEvent,
KeyboardEvent,
memo,
useCallback,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
@ -29,7 +36,7 @@ import { useDebounce } from 'use-debounce';
const PER_PAGE = 10;
const ORDER_BY_DATA: SelectItem[] = [
{ value: 'opened_at', label: 'Recently Opened' },
{ value: 'opened_at', label: 'Opened' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
{ value: 'name', label: 'Name' },
@ -48,14 +55,29 @@ const WorkflowLibraryList = () => {
const [order_by, setOrderBy] = useState<WorkflowRecordOrderBy>('opened_at');
const [direction, setDirection] = useState<SQLiteDirection>('ASC');
const [debouncedFilterText] = useDebounce(filter_text, 500);
const { data, isLoading, isError, isFetching } = useListWorkflowsQuery({
page,
per_page: PER_PAGE,
order_by,
direction,
category,
filter_text: debouncedFilterText,
});
const query = useMemo(() => {
if (category === 'user') {
return {
page,
per_page: PER_PAGE,
order_by,
direction,
category,
filter_text: debouncedFilterText,
};
}
return {
page,
per_page: PER_PAGE,
order_by: 'name' as const,
direction: 'ASC' as const,
category,
filter_text: debouncedFilterText,
};
}, [category, debouncedFilterText, direction, order_by, page]);
const { data, isLoading, isError, isFetching } = useListWorkflowsQuery(query);
const handleChangeOrderBy = useCallback(
(value: string | null) => {
@ -106,10 +128,12 @@ const WorkflowLibraryList = () => {
const handleSetUserCategory = useCallback(() => {
setCategory('user');
setPage(0);
}, []);
const handleSetSystemCategory = useCallback(() => {
setCategory('system');
const handleSetDefaultCategory = useCallback(() => {
setCategory('default');
setPage(0);
}, []);
if (isLoading) {
@ -132,14 +156,44 @@ const WorkflowLibraryList = () => {
{t('workflows.userWorkflows')}
</IAIButton>
<IAIButton
variant={category === 'system' ? undefined : 'ghost'}
onClick={handleSetSystemCategory}
isChecked={category === 'system'}
variant={category === 'default' ? undefined : 'ghost'}
onClick={handleSetDefaultCategory}
isChecked={category === 'default'}
>
{t('workflows.systemWorkflows')}
{t('workflows.defaultWorkflows')}
</IAIButton>
</ButtonGroup>
<Spacer />
{category === 'user' && (
<>
<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}
/>
</>
)}
<InputGroup w="20rem">
<Input
placeholder={t('workflows.searchWorkflows')}
@ -161,32 +215,6 @@ const WorkflowLibraryList = () => {
</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 ? (

View File

@ -36,11 +36,13 @@ const WorkflowLibraryListItem = ({ workflowDTO }: Props) => {
{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>
{workflowDTO.category === 'user' && (
<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 ? (
@ -58,11 +60,13 @@ const WorkflowLibraryListItem = ({ workflowDTO }: Props) => {
</Text>
)}
<Spacer />
<Text fontSize="sm" variant="subtext">
{t('common.created')}:{' '}
{dateFormat(workflowDTO.created_at, masks.shortDate)}{' '}
{dateFormat(workflowDTO.created_at, masks.shortTime)}
</Text>
{workflowDTO.category === 'user' && (
<Text fontSize="sm" variant="subtext">
{t('common.created')}:{' '}
{dateFormat(workflowDTO.created_at, masks.shortDate)}{' '}
{dateFormat(workflowDTO.created_at, masks.shortTime)}
</Text>
)}
</Flex>
</Flex>
<IAIButton

View File

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

@ -1,117 +0,0 @@
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);

File diff suppressed because one or more lines are too long

View File

@ -165,7 +165,7 @@ version = { attr = "invokeai.version.__version__" }
[tool.setuptools.package-data]
"invokeai.app.assets" = ["**/*.png"]
"invokeai.app.services.workflow_records.system_workflows" = ["*.json"]
"invokeai.app.services.workflow_records.default_workflows" = ["*.json"]
"invokeai.assets.fonts" = ["**/*.ttf"]
"invokeai.backend" = ["**.png"]
"invokeai.configs" = ["*.example", "**/*.yaml", "*.txt"]