From b5308099b52e68f792780105f55d34277cbce1ad Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 6 Dec 2025 20:10:34 +0100 Subject: [PATCH] Feature: Add Tag System for user made Workflows --- invokeai/app/api/routers/workflows.py | 9 ++ .../workflow_records/workflow_records_base.py | 8 + .../workflow_records_sqlite.py | 42 +++++ .../WorkflowLibrarySideNav.tsx | 144 ++++++++++++++++-- .../workflow/WorkflowLibrary/WorkflowList.tsx | 2 +- .../src/services/api/endpoints/workflows.ts | 10 ++ .../frontend/web/src/services/api/index.ts | 1 + .../frontend/web/src/services/api/schema.ts | 52 +++++++ 8 files changed, 254 insertions(+), 14 deletions(-) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 5a37a75dcf9..72d50a416b4 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -223,6 +223,15 @@ async def get_workflow_thumbnail( raise HTTPException(status_code=404) +@workflows_router.get("/tags", operation_id="get_all_tags") +async def get_all_tags( + categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"), +) -> list[str]: + """Gets all unique tags from workflows""" + + return ApiDependencies.invoker.services.workflow_records.get_all_tags(categories=categories) + + @workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag") async def get_counts_by_tag( tags: list[str] = Query(description="The tags to get counts for"), diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py index 5bf42ed2533..d5cf319594b 100644 --- a/invokeai/app/services/workflow_records/workflow_records_base.py +++ b/invokeai/app/services/workflow_records/workflow_records_base.py @@ -74,3 +74,11 @@ def counts_by_tag( def update_opened_at(self, workflow_id: str) -> None: """Open a workflow.""" pass + + @abstractmethod + def get_all_tags( + self, + categories: Optional[list[WorkflowCategory]] = None, + ) -> list[str]: + """Gets all unique tags from workflows.""" + pass diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index d6a94d156f0..0f72f7cd92c 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -332,6 +332,48 @@ def update_opened_at(self, workflow_id: str) -> None: (workflow_id,), ) + def get_all_tags( + self, + categories: Optional[list[WorkflowCategory]] = None, + ) -> list[str]: + with self._db.transaction() as cursor: + conditions: list[str] = [] + params: list[str] = [] + + # Only get workflows that have tags + conditions.append("tags IS NOT NULL AND tags != ''") + + if categories: + assert all(c in WorkflowCategory for c in categories) + placeholders = ", ".join("?" for _ in categories) + conditions.append(f"category IN ({placeholders})") + params.extend([category.value for category in categories]) + + stmt = """--sql + SELECT DISTINCT tags + FROM workflow_library + """ + + if conditions: + stmt += " WHERE " + " AND ".join(conditions) + + cursor.execute(stmt, params) + rows = cursor.fetchall() + + # Parse comma-separated tags and collect unique tags + all_tags: set[str] = set() + + for row in rows: + tags_value = row[0] + if tags_value and isinstance(tags_value, str): + # Tags are stored as comma-separated string + for tag in tags_value.split(","): + tag_stripped = tag.strip() + if tag_stripped: + all_tags.add(tag_stripped) + + return sorted(all_tags) + def _sync_default_workflows(self) -> None: """Syncs default workflows to the database. Internal use only.""" diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index dedfdcc6599..73b046c83a9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -31,7 +31,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiStarFill } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; -import { useGetCountsByTagQuery } from 'services/api/endpoints/workflows'; +import { useGetAllTagsQuery, useGetCountsByTagQuery } from 'services/api/endpoints/workflows'; export const WorkflowLibrarySideNav = () => { const { t } = useTranslation(); @@ -40,11 +40,11 @@ export const WorkflowLibrarySideNav = () => { {t('workflows.recentlyOpened')} - {t('workflows.yourWorkflows')} + - + @@ -53,6 +53,40 @@ export const WorkflowLibrarySideNav = () => { ); }; +const YourWorkflowsButton = memo(() => { + const { t } = useTranslation(); + const view = useAppSelector(selectWorkflowLibraryView); + const dispatch = useAppDispatch(); + const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags); + const resetTags = useCallback(() => { + dispatch(workflowLibraryTagsReset()); + }, [dispatch]); + + if (view === 'yours' && selectedTags.length > 0) { + return ( + + + {t('workflows.yourWorkflows')} + + + } + variant="ghost" + bg="base.700" + color="base.50" + /> + + + ); + } + + return {t('workflows.yourWorkflows')}; +}); +YourWorkflowsButton.displayName = 'YourWorkflowsButton'; + const BrowseWorkflowsButton = memo(() => { const { t } = useTranslation(); const view = useAppSelector(selectWorkflowLibraryView); @@ -89,31 +123,114 @@ BrowseWorkflowsButton.displayName = 'BrowseWorkflowsButton'; const overlayscrollbarsOptions = getOverlayScrollbarsParams({ visibility: 'visible' }).options; -const DefaultsViewCheckboxesCollapsible = memo(() => { +const TagCheckboxesCollapsible = memo(() => { const view = useAppSelector(selectWorkflowLibraryView); return ( - + - {WORKFLOW_LIBRARY_TAG_CATEGORIES.map((tagCategory) => ( - - ))} + {view === 'yours' ? : } ); }); -DefaultsViewCheckboxesCollapsible.displayName = 'DefaultsViewCheckboxes'; +TagCheckboxesCollapsible.displayName = 'TagCheckboxesCollapsible'; -const tagCountQueryArg = { - tags: WORKFLOW_LIBRARY_TAGS.map((tag) => tag.label), - categories: ['default'], -} satisfies Parameters[0]; +const StaticTagCategories = memo(() => { + return ( + <> + {WORKFLOW_LIBRARY_TAG_CATEGORIES.map((tagCategory) => ( + + ))} + + ); +}); +StaticTagCategories.displayName = 'StaticTagCategories'; + +const DynamicTagsList = memo(() => { + const { t } = useTranslation(); + const { data: tags, isLoading } = useGetAllTagsQuery({ categories: ['user'] }); + + if (isLoading) { + return {t('common.loading')}; + } + + if (!tags || tags.length === 0) { + return null; + } + + return ( + + {tags.map((tag) => ( + + ))} + + ); +}); +DynamicTagsList.displayName = 'DynamicTagsList'; + +const DynamicTagCheckbox = memo(({ tag }: { tag: string }) => { + const dispatch = useAppDispatch(); + const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags); + const isChecked = selectedTags.includes(tag); + const count = useDynamicTagCount(tag); + + const onChange = useCallback(() => { + dispatch(workflowLibraryTagToggled(tag)); + }, [dispatch, tag]); + + if (count === 0) { + return null; + } + + return ( + + + {`${tag} (${count})`} + + ); +}); +DynamicTagCheckbox.displayName = 'DynamicTagCheckbox'; + +const useDynamicTagCount = (tag: string) => { + const queryArg = useMemo( + () => ({ + tags: [tag], + categories: ['user'] as ('user' | 'default')[], + }), + [tag] + ); + + const queryOptions = useMemo( + () => ({ + selectFromResult: ({ data }: { data?: Record }) => ({ + count: data?.[tag] ?? 0, + }), + }), + [tag] + ); + + const { count } = useGetCountsByTagQuery(queryArg, queryOptions); + return count; +}; + +const useTagCountQueryArg = () => { + const view = useAppSelector(selectWorkflowLibraryView); + return useMemo( + () => ({ + tags: WORKFLOW_LIBRARY_TAGS.map((tag) => tag.label), + categories: view === 'yours' ? ['user'] : ['default'], + }), + [view] + ) satisfies Parameters[0]; +}; const useCountForIndividualTag = (tag: string) => { + const tagCountQueryArg = useTagCountQueryArg(); const queryOptions = useMemo( () => ({ @@ -130,6 +247,7 @@ const useCountForIndividualTag = (tag: string) => { }; const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => { + const tagCountQueryArg = useTagCountQueryArg(); const queryOptions = useMemo( () => ({ diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx index 203a1f7a319..79dff535b05 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx @@ -60,7 +60,7 @@ const useInfiniteQueryAry = () => { direction, categories: getCategories(view), query: debouncedSearchTerm, - tags: view === 'defaults' ? selectedTags : [], + tags: view === 'defaults' || view === 'yours' ? selectedTags : [], has_been_opened: getHasBeenOpened(view), } satisfies Parameters[0]; }, [orderBy, direction, view, debouncedSearchTerm, selectedTags]); diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts index b9a02204fc4..f58d3281a26 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts @@ -30,6 +30,7 @@ export const workflowsApi = api.injectEndpoints({ // Because this may change the order of the list, we need to invalidate the whole list { type: 'Workflow', id: LIST_TAG }, { type: 'Workflow', id: workflow_id }, + 'WorkflowTags', 'WorkflowTagCounts', 'WorkflowCategoryCounts', ], @@ -46,6 +47,7 @@ export const workflowsApi = api.injectEndpoints({ invalidatesTags: [ // Because this may change the order of the list, we need to invalidate the whole list { type: 'Workflow', id: LIST_TAG }, + 'WorkflowTags', 'WorkflowTagCounts', 'WorkflowCategoryCounts', ], @@ -61,10 +63,17 @@ export const workflowsApi = api.injectEndpoints({ }), invalidatesTags: (response, error, workflow) => [ { type: 'Workflow', id: workflow.id }, + 'WorkflowTags', 'WorkflowTagCounts', 'WorkflowCategoryCounts', ], }), + getAllTags: build.query({ + query: (params) => ({ + url: `${buildWorkflowsUrl('tags')}${params ? `?${queryString.stringify(params, { arrayFormat: 'none' })}` : ''}`, + }), + providesTags: ['WorkflowTags'], + }), getCountsByTag: build.query< paths['/api/v1/workflows/counts_by_tag']['get']['responses']['200']['content']['application/json'], NonNullable @@ -153,6 +162,7 @@ export const workflowsApi = api.injectEndpoints({ export const { useUpdateOpenedAtMutation, + useGetAllTagsQuery, useGetCountsByTagQuery, useGetCountsByCategoryQuery, useLazyGetWorkflowQuery, diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index d5b1e4672a8..fdd30029a75 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -47,6 +47,7 @@ const tagTypes = [ 'LoRAModel', 'SDXLRefinerModel', 'Workflow', + 'WorkflowTags', 'WorkflowTagCounts', 'WorkflowCategoryCounts', 'StylePreset', diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index c52d6f1744c..af54a20e1d4 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1688,6 +1688,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/workflows/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Tags + * @description Gets all unique tags from workflows + */ + get: operations["get_all_tags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/workflows/counts_by_tag": { parameters: { query?: never; @@ -28145,6 +28165,38 @@ export interface operations { }; }; }; + get_all_tags: { + parameters: { + query?: { + /** @description The categories to include */ + categories?: components["schemas"]["WorkflowCategory"][] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_counts_by_tag: { parameters: { query: {