From 19469d5edef44cb3bb5d18ec13c376272915ed8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Nov 2025 01:33:03 +0000 Subject: [PATCH 1/7] feat: Add bulk delete functionality for models, LoRAs, and embeddings Implements a comprehensive bulk deletion feature for the model manager that allows users to select and delete multiple models, LoRAs, and embeddings at once. Key changes: Frontend: - Add multi-selection state management to modelManagerV2 slice - Update ModelListItem to support Ctrl/Cmd+Click multi-selection with checkboxes - Create ModelListHeader component showing selection count and bulk actions - Create BulkDeleteModelsModal for confirming bulk deletions - Integrate bulk delete UI into ModelList with proper error handling - Add API mutation for bulk delete operations Backend: - Add POST /api/v2/models/i/bulk_delete endpoint - Implement BulkDeleteModelsRequest and BulkDeleteModelsResponse schemas - Handle partial failures with detailed error reporting - Return lists of successfully deleted and failed models This feature significantly improves user experience when managing large model libraries, especially when restructuring model storage locations. Fixes issue where users had to delete models individually after moving model files to new storage locations. --- invokeai/app/api/routers/model_manager.py | 53 ++++++++ .../store/modelManagerV2Slice.ts | 22 ++- .../BulkDeleteModelsModal.tsx | 67 ++++++++++ .../subpanels/ModelManagerPanel/ModelList.tsx | 126 +++++++++++++++--- .../ModelManagerPanel/ModelListHeader.tsx | 59 ++++++++ .../ModelManagerPanel/ModelListItem.tsx | 47 ++++++- .../web/src/services/api/endpoints/models.ts | 17 +++ 7 files changed, 368 insertions(+), 23 deletions(-) create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkDeleteModelsModal.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 24b1ba61f8d..06f7dd4e665 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -447,6 +447,59 @@ async def delete_model( raise HTTPException(status_code=404, detail=str(e)) +class BulkDeleteModelsRequest(BaseModel): + """Request body for bulk model deletion.""" + + keys: List[str] = Field(description="List of model keys to delete") + + +class BulkDeleteModelsResponse(BaseModel): + """Response body for bulk model deletion.""" + + deleted: List[str] = Field(description="List of successfully deleted model keys") + failed: List[dict] = Field(description="List of failed deletions with error messages") + + +@model_manager_router.post( + "/i/bulk_delete", + operation_id="bulk_delete_models", + responses={ + 200: {"description": "Models deleted (possibly with some failures)"}, + }, + status_code=200, +) +async def bulk_delete_models( + request: BulkDeleteModelsRequest = Body(description="List of model keys to delete"), +) -> BulkDeleteModelsResponse: + """ + Delete multiple model records from database. + + The configuration records will be removed. The corresponding weights files will be + deleted as well if they reside within the InvokeAI "models" directory. + Returns a list of successfully deleted keys and failed deletions with error messages. + """ + logger = ApiDependencies.invoker.services.logger + installer = ApiDependencies.invoker.services.model_manager.install + + deleted = [] + failed = [] + + for key in request.keys: + try: + installer.delete(key) + deleted.append(key) + logger.info(f"Deleted model: {key}") + except UnknownModelException as e: + logger.error(f"Failed to delete model {key}: {str(e)}") + failed.append({"key": key, "error": str(e)}) + except Exception as e: + logger.error(f"Failed to delete model {key}: {str(e)}") + failed.append({"key": key, "error": str(e)}) + + logger.info(f"Bulk delete completed: {len(deleted)} deleted, {len(failed)} failed") + return BulkDeleteModelsResponse(deleted=deleted, failed=failed) + + @model_manager_router.delete( "/i/{key}/image", operation_id="delete_model_image", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts b/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts index 7f20f21c22a..65c9cbc1302 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts +++ b/invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts @@ -18,6 +18,7 @@ const zModelManagerState = z.object({ filteredModelType: zFilterableModelType.nullable(), scanPath: z.string().optional(), shouldInstallInPlace: z.boolean(), + selectedModelKeys: z.array(z.string()), }); type ModelManagerState = z.infer; @@ -30,6 +31,7 @@ const getInitialState = (): ModelManagerState => ({ searchTerm: '', scanPath: undefined, shouldInstallInPlace: true, + selectedModelKeys: [], }); const slice = createSlice({ @@ -55,6 +57,20 @@ const slice = createSlice({ shouldInstallInPlaceChanged: (state, action: PayloadAction) => { state.shouldInstallInPlace = action.payload; }, + modelSelectionChanged: (state, action: PayloadAction) => { + state.selectedModelKeys = action.payload; + }, + toggleModelSelection: (state, action: PayloadAction) => { + const index = state.selectedModelKeys.indexOf(action.payload); + if (index > -1) { + state.selectedModelKeys.splice(index, 1); + } else { + state.selectedModelKeys.push(action.payload); + } + }, + clearModelSelection: (state) => { + state.selectedModelKeys = []; + }, }, }); @@ -65,6 +81,9 @@ export const { setSelectedModelMode, setScanPath, shouldInstallInPlaceChanged, + modelSelectionChanged, + toggleModelSelection, + clearModelSelection, } = slice.actions; export const modelManagerSliceConfig: SliceConfig = { @@ -79,7 +98,7 @@ export const modelManagerSliceConfig: SliceConfig = { } return zModelManagerState.parse(state); }, - persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm'], + persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm', 'selectedModelKeys'], }, }; @@ -93,3 +112,4 @@ export const selectSelectedModelMode = createModelManagerSelector((modelManager) export const selectSearchTerm = createModelManagerSelector((mm) => mm.searchTerm); export const selectFilteredModelType = createModelManagerSelector((mm) => mm.filteredModelType); export const selectShouldInstallInPlace = createModelManagerSelector((mm) => mm.shouldInstallInPlace); +export const selectSelectedModelKeys = createModelManagerSelector((mm) => mm.selectedModelKeys); diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkDeleteModelsModal.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkDeleteModelsModal.tsx new file mode 100644 index 00000000000..d1469eaffb7 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkDeleteModelsModal.tsx @@ -0,0 +1,67 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Button, + Flex, + Text, +} from '@invoke-ai/ui-library'; +import { memo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +type BulkDeleteModelsModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + modelCount: number; + isDeleting?: boolean; +}; + +export const BulkDeleteModelsModal = memo( + ({ isOpen, onClose, onConfirm, modelCount, isDeleting = false }: BulkDeleteModelsModalProps) => { + const { t } = useTranslation(); + const cancelRef = useRef(null); + + return ( + + + + + {t('modelManager.deleteModels', { count: modelCount })} + + + + + + {t('modelManager.deleteModelsConfirm', { + count: modelCount, + defaultValue: `Are you sure you want to delete ${modelCount} model(s)? This action cannot be undone.`, + })} + + + {t('modelManager.deleteWarning', { + defaultValue: 'Models in your Invoke models directory will be permanently deleted from disk.', + })} + + + + + + + + + + + + ); + } +); + +BulkDeleteModelsModal.displayName = 'BulkDeleteModelsModal'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx index bde3f1d5946..aaffff44778 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -1,29 +1,44 @@ -import { Flex, Text } from '@invoke-ai/ui-library'; +import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; -import { useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models'; import { + clearModelSelection, type FilterableModelType, selectFilteredModelType, selectSearchTerm, + selectSelectedModelKeys, + setSelectedModelKey, } from 'features/modelManagerV2/store/modelManagerV2Slice'; -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models'; +import { + modelConfigsAdapterSelectors, + useBulkDeleteModelsMutation, + useGetModelConfigsQuery, +} from 'services/api/endpoints/models'; import type { AnyModelConfig } from 'services/api/types'; +import { BulkDeleteModelsModal } from './BulkDeleteModelsModal'; import { FetchingModelsLoader } from './FetchingModelsLoader'; +import { ModelListHeader } from './ModelListHeader'; import { ModelListWrapper } from './ModelListWrapper'; const log = logger('models'); const ModelList = () => { + const dispatch = useAppDispatch(); const filteredModelType = useAppSelector(selectFilteredModelType); const searchTerm = useAppSelector(selectSearchTerm); + const selectedModelKeys = useAppSelector(selectSelectedModelKeys); const { t } = useTranslation(); + const toast = useToast(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [isDeleting, setIsDeleting] = useState(false); const { data, isLoading } = useGetModelConfigsQuery(); + const [bulkDeleteModels] = useBulkDeleteModelsMutation(); const models = useMemo(() => { const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); @@ -46,20 +61,97 @@ const ModelList = () => { return { total, byCategory }; }, [data, filteredModelType, searchTerm]); + const handleBulkDelete = useCallback(() => { + onOpen(); + }, [onOpen]); + + const handleConfirmBulkDelete = useCallback(async () => { + setIsDeleting(true); + try { + const result = await bulkDeleteModels({ keys: selectedModelKeys }).unwrap(); + + // Clear selection and close modal + dispatch(clearModelSelection()); + dispatch(setSelectedModelKey(null)); + onClose(); + + // Show success/failure toast + if (result.failed.length === 0) { + toast({ + id: 'BULK_DELETE_SUCCESS', + title: t('modelManager.modelsDeleted', { + count: result.deleted.length, + defaultValue: `Successfully deleted ${result.deleted.length} model(s)`, + }), + status: 'success', + }); + } else if (result.deleted.length === 0) { + toast({ + id: 'BULK_DELETE_FAILED', + title: t('modelManager.modelsDeleteFailed', { + defaultValue: 'Failed to delete models', + }), + description: t('modelManager.someModelsFailedToDelete', { + count: result.failed.length, + defaultValue: `${result.failed.length} model(s) could not be deleted`, + }), + status: 'error', + }); + } else { + // Partial success + toast({ + id: 'BULK_DELETE_PARTIAL', + title: t('modelManager.modelsDeletedPartial', { + defaultValue: 'Partially completed', + }), + description: t('modelManager.someModelsDeleted', { + deleted: result.deleted.length, + failed: result.failed.length, + defaultValue: `${result.deleted.length} deleted, ${result.failed.length} failed`, + }), + status: 'warning', + }); + } + + log.info(`Bulk delete completed: ${result.deleted.length} deleted, ${result.failed.length} failed`); + } catch (error) { + log.error('Bulk delete error:', error); + toast({ + id: 'BULK_DELETE_ERROR', + title: t('modelManager.modelsDeleteError', { + defaultValue: 'Error deleting models', + }), + status: 'error', + }); + } finally { + setIsDeleting(false); + } + }, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t, log]); + return ( - - - {isLoading && } - {models.byCategory.map(({ i18nKey, configs }) => ( - - ))} - {!isLoading && models.total === 0 && ( - - {t('modelManager.noMatchingModels')} - - )} - - + <> + + + + {isLoading && } + {models.byCategory.map(({ i18nKey, configs }) => ( + + ))} + {!isLoading && models.total === 0 && ( + + {t('modelManager.noMatchingModels')} + + )} + + + + ); }; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx new file mode 100644 index 00000000000..717b9b123f4 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx @@ -0,0 +1,59 @@ +import { Button, Flex, Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { clearModelSelection, selectSelectedModelKeys } from 'features/modelManagerV2/store/modelManagerV2Slice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleBold } from 'react-icons/pi'; + +type ModelListHeaderProps = { + onBulkDelete: () => void; +}; + +export const ModelListHeader = memo(({ onBulkDelete }: ModelListHeaderProps) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedModelKeys = useAppSelector(selectSelectedModelKeys); + const selectionCount = selectedModelKeys.length; + + const handleClearSelection = useCallback(() => { + dispatch(clearModelSelection()); + }, [dispatch]); + + if (selectionCount === 0) { + return null; + } + + return ( + + + + {selectionCount} {t('common.selected')} + + + + + + ); +}); + +ModelListHeader.displayName = 'ModelListHeader'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx index dc7b2122a89..b0e6b206f5a 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx @@ -1,12 +1,18 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Flex, Spacer, Text } from '@invoke-ai/ui-library'; +import { Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectModelManagerV2Slice, setSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice'; +import { + selectModelManagerV2Slice, + selectSelectedModelKeys, + setSelectedModelKey, + toggleModelSelection, +} from 'features/modelManagerV2/store/modelManagerV2Slice'; import ModelBaseBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge'; import ModelFormatBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge'; import { ModelDeleteButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelDeleteButton'; import { filesize } from 'filesize'; +import type { MouseEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; import type { AnyModelConfig } from 'services/api/types'; @@ -62,15 +68,37 @@ const ModelListItem = ({ model }: ModelListItemProps) => { [model.key] ); const isSelected = useAppSelector(selectIsSelected); + const selectedModelKeys = useAppSelector(selectSelectedModelKeys); + const isChecked = selectedModelKeys.includes(model.key); + const hasSelection = selectedModelKeys.length > 0; - const handleSelectModel = useCallback(() => { - dispatch(setSelectedModelKey(model.key)); + const handleSelectModel = useCallback( + (e: MouseEvent) => { + // Check if clicked on checkbox or delete button - if so, don't handle selection + const target = e.target as HTMLElement; + if (target.closest('input[type="checkbox"]') || target.closest('button')) { + return; + } + + // Multi-select with Ctrl/Cmd+Click + if (e.ctrlKey || e.metaKey) { + dispatch(toggleModelSelection(model.key)); + } else { + // Single select - normal behavior + dispatch(setSelectedModelKey(model.key)); + } + }, + [model.key, dispatch] + ); + + const handleCheckboxChange = useCallback(() => { + dispatch(toggleModelSelection(model.key)); }, [model.key, dispatch]); return ( { cursor="pointer" onClick={handleSelectModel} > + {hasSelection && ( + e.stopPropagation()} + /> + )} diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index 51b4a84bdc1..cd18901a06e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -43,6 +43,12 @@ type DeleteModelArg = { type DeleteModelResponse = void; type DeleteModelImageResponse = void; +type BulkDeleteModelsArg = { + keys: string[]; +}; +type BulkDeleteModelsResponse = + paths['/api/v2/models/i/bulk_delete']['post']['responses']['200']['content']['application/json']; + type ConvertMainModelResponse = paths['/api/v2/models/convert/{key}']['put']['responses']['200']['content']['application/json']; @@ -151,6 +157,16 @@ export const modelsApi = api.injectEndpoints({ }, invalidatesTags: [{ type: 'ModelConfig', id: LIST_TAG }], }), + bulkDeleteModels: build.mutation({ + query: ({ keys }) => { + return { + url: buildModelsUrl(`i/bulk_delete`), + method: 'POST', + body: { keys }, + }; + }, + invalidatesTags: [{ type: 'ModelConfig', id: LIST_TAG }], + }), deleteModelImage: build.mutation({ query: (key) => { return { @@ -340,6 +356,7 @@ export const { useGetModelConfigsQuery, useGetModelConfigQuery, useDeleteModelsMutation, + useBulkDeleteModelsMutation, useDeleteModelImageMutation, useUpdateModelMutation, useUpdateModelImageMutation, From fb473385385ba50856cdb1daf03983be17d40ed9 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 12 Nov 2025 23:33:56 +0100 Subject: [PATCH 2/7] fix: prevent model list header from scrolling with content --- .../subpanels/ModelManagerPanel/ModelList.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx index aaffff44778..d184a42b614 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -130,20 +130,22 @@ const ModelList = () => { return ( <> - - - - {isLoading && } - {models.byCategory.map(({ i18nKey, configs }) => ( - - ))} - {!isLoading && models.total === 0 && ( - - {t('modelManager.noMatchingModels')} - - )} - - + + + + + {isLoading && } + {models.byCategory.map(({ i18nKey, configs }) => ( + + ))} + {!isLoading && models.total === 0 && ( + + {t('modelManager.noMatchingModels')} + + )} + + + Date: Wed, 12 Nov 2025 23:49:42 +0100 Subject: [PATCH 3/7] fix: improve error handling in bulk model deletion - Added proper error serialization using serialize-error for better error logging - Explicitly defined BulkDeleteModelsResponse type instead of relying on generated schema reference --- .../subpanels/ModelManagerPanel/ModelList.tsx | 5 +- .../web/src/services/api/endpoints/models.ts | 6 +- .../frontend/web/src/services/api/schema.ts | 90 ++++++++++++++++++- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx index d184a42b614..c98c132e5a1 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -2,6 +2,7 @@ import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { serializeError } from 'serialize-error'; import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models'; import { clearModelSelection, @@ -114,8 +115,8 @@ const ModelList = () => { } log.info(`Bulk delete completed: ${result.deleted.length} deleted, ${result.failed.length} failed`); - } catch (error) { - log.error('Bulk delete error:', error); + } catch (err) { + log.error({ error: serializeError(err) }, 'Bulk delete error'); toast({ id: 'BULK_DELETE_ERROR', title: t('modelManager.modelsDeleteError', { diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index cd18901a06e..707352bcb39 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -46,8 +46,10 @@ type DeleteModelImageResponse = void; type BulkDeleteModelsArg = { keys: string[]; }; -type BulkDeleteModelsResponse = - paths['/api/v2/models/i/bulk_delete']['post']['responses']['200']['content']['application/json']; +type BulkDeleteModelsResponse = { + deleted: string[]; + failed: string[]; +}; type ConvertMainModelResponse = paths['/api/v2/models/convert/{key}']['put']['responses']['200']['content']['application/json']; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 40214ffa554..b00e115836e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -167,6 +167,30 @@ export type paths = { patch: operations["update_model_image"]; trace?: never; }; + "/api/v2/models/i/bulk_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete Models + * @description Delete multiple model records from database. + * + * The configuration records will be removed. The corresponding weights files will be + * deleted as well if they reside within the InvokeAI "models" directory. + * Returns a list of successfully deleted keys and failed deletions with error messages. + */ + post: operations["bulk_delete_models"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/install": { parameters: { query?: never; @@ -3006,6 +3030,35 @@ export type components = { */ type: "bounding_box_output"; }; + /** + * BulkDeleteModelsRequest + * @description Request body for bulk model deletion. + */ + BulkDeleteModelsRequest: { + /** + * Keys + * @description List of model keys to delete + */ + keys: string[]; + }; + /** + * BulkDeleteModelsResponse + * @description Response body for bulk model deletion. + */ + BulkDeleteModelsResponse: { + /** + * Deleted + * @description List of successfully deleted model keys + */ + deleted: string[]; + /** + * Failed + * @description List of failed deletions with error messages + */ + failed: { + [key: string]: unknown; + }[]; + }; /** * BulkDownloadCompleteEvent * @description Event model for bulk_download_complete @@ -12940,14 +12993,14 @@ export type components = { * Convert Cache Dir * Format: path * @description Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions). - * @default models/.convert_cache + * @default models\.convert_cache */ convert_cache_dir?: string; /** * Download Cache Dir * Format: path * @description Path to the directory that contains dynamically downloaded models. - * @default models/.download_cache + * @default models\.download_cache */ download_cache_dir?: string; /** @@ -24943,6 +24996,39 @@ export interface operations { }; }; }; + bulk_delete_models: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkDeleteModelsRequest"]; + }; + }; + responses: { + /** @description Models deleted (possibly with some failures) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkDeleteModelsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_model_installs: { parameters: { query?: never; From c10e92c89a0d04a71c1a5dfa438b09b36b2cbbec Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 12 Nov 2025 23:56:52 +0100 Subject: [PATCH 4/7] refactor: improve code organization in ModelList components - Reordered imports to follow conventional grouping (external, internal, then third-party utilities) - Added type assertion for error serialization to satisfy TypeScript - Extracted inline event handler into named callback function for better readability --- .../subpanels/ModelManagerPanel/ModelList.tsx | 6 +++--- .../subpanels/ModelManagerPanel/ModelListItem.tsx | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx index c98c132e5a1..0bf496b583b 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -2,7 +2,6 @@ import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; -import { serializeError } from 'serialize-error'; import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models'; import { clearModelSelection, @@ -14,6 +13,7 @@ import { } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { serializeError } from 'serialize-error'; import { modelConfigsAdapterSelectors, useBulkDeleteModelsMutation, @@ -116,7 +116,7 @@ const ModelList = () => { log.info(`Bulk delete completed: ${result.deleted.length} deleted, ${result.failed.length} failed`); } catch (err) { - log.error({ error: serializeError(err) }, 'Bulk delete error'); + log.error({ error: serializeError(err as Error) }, 'Bulk delete error'); toast({ id: 'BULK_DELETE_ERROR', title: t('modelManager.modelsDeleteError', { @@ -127,7 +127,7 @@ const ModelList = () => { } finally { setIsDeleting(false); } - }, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t, log]); + }, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t]); return ( <> diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx index b0e6b206f5a..4400a29350b 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx @@ -95,6 +95,10 @@ const ModelListItem = ({ model }: ModelListItemProps) => { dispatch(toggleModelSelection(model.key)); }, [model.key, dispatch]); + const handleCheckboxClick = useCallback((e: MouseEvent) => { + e.stopPropagation(); + }, []); + return ( { onChange={handleCheckboxChange} mt={1} pointerEvents="auto" - onClick={(e) => e.stopPropagation()} + onClick={handleCheckboxClick} /> )} From ced5c8a3918420f430d6d65712d0ae08ad71fdcc Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 12 Nov 2025 23:57:16 +0100 Subject: [PATCH 5/7] refactor: consolidate Button component props to single line --- .../subpanels/ModelManagerPanel/ModelListHeader.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx index 717b9b123f4..50e66c41733 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx @@ -43,13 +43,7 @@ export const ModelListHeader = memo(({ onBulkDelete }: ModelListHeaderProps) => - From 6d18aa85bce4e4ab3ae47e26c0758ca277031932 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 21 Nov 2025 05:29:36 +0100 Subject: [PATCH 6/7] feat(ui): enhance model manager bulk selection with select-all and actions menu - Added select-all checkbox in navigation header with indeterminate state support - Replaced single delete button with actions dropdown menu for future extensibility - Made checkboxes always visible instead of conditionally showing on selection - Moved model filtering logic to ModelListNavigation for select-all functionality - Improved UX by showing selection state for filtered models only --- .../ModelManagerPanel/ModelListHeader.tsx | 27 +++- .../ModelManagerPanel/ModelListItem.tsx | 21 ++- .../ModelManagerPanel/ModelListNavigation.tsx | 137 +++++++++++++++--- 3 files changed, 145 insertions(+), 40 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx index 50e66c41733..3d0d71029b8 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx @@ -1,9 +1,19 @@ -import { Button, Flex, Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; +import { + Button, + Flex, + Menu, + MenuButton, + MenuItem, + MenuList, + Tag, + TagCloseButton, + TagLabel, +} from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { clearModelSelection, selectSelectedModelKeys } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiTrashSimpleBold } from 'react-icons/pi'; +import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi'; type ModelListHeaderProps = { onBulkDelete: () => void; @@ -43,9 +53,16 @@ export const ModelListHeader = memo(({ onBulkDelete }: ModelListHeaderProps) => - + + } flexShrink={0}> + {t('modelManager.actions')} + + + } onClick={onBulkDelete} color="error.300"> + {t('modelManager.deleteModels', { count: selectionCount })} + + + ); }); diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx index 4400a29350b..d6dda98e80e 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx @@ -70,7 +70,6 @@ const ModelListItem = ({ model }: ModelListItemProps) => { const isSelected = useAppSelector(selectIsSelected); const selectedModelKeys = useAppSelector(selectSelectedModelKeys); const isChecked = selectedModelKeys.includes(model.key); - const hasSelection = selectedModelKeys.length > 0; const handleSelectModel = useCallback( (e: MouseEvent) => { @@ -80,11 +79,11 @@ const ModelListItem = ({ model }: ModelListItemProps) => { return; } - // Multi-select with Ctrl/Cmd+Click + // Clicking the row opens detail view (single select) + // Ctrl/Cmd+Click still works as a power user feature for multi-select if (e.ctrlKey || e.metaKey) { dispatch(toggleModelSelection(model.key)); } else { - // Single select - normal behavior dispatch(setSelectedModelKey(model.key)); } }, @@ -110,15 +109,13 @@ const ModelListItem = ({ model }: ModelListItemProps) => { cursor="pointer" onClick={handleSelectModel} > - {hasSelection && ( - - )} + diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx index 0d9c9259d48..23081f68cf3 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx @@ -1,16 +1,48 @@ -import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library'; +import { Checkbox, Flex, IconButton, Input, InputGroup, InputRightElement, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectSearchTerm, setSearchTerm } from 'features/modelManagerV2/store/modelManagerV2Slice'; +import { + type FilterableModelType, + modelSelectionChanged, + selectFilteredModelType, + selectSearchTerm, + selectSelectedModelKeys, + setSearchTerm, +} from 'features/modelManagerV2/store/modelManagerV2Slice'; import { t } from 'i18next'; import type { ChangeEventHandler } from 'react'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { PiXBold } from 'react-icons/pi'; +import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models'; +import type { AnyModelConfig } from 'services/api/types'; import { ModelTypeFilter } from './ModelTypeFilter'; export const ModelListNavigation = memo(() => { const dispatch = useAppDispatch(); const searchTerm = useAppSelector(selectSearchTerm); + const filteredModelType = useAppSelector(selectFilteredModelType); + const selectedModelKeys = useAppSelector(selectSelectedModelKeys); + const { data } = useGetModelConfigsQuery(); + + // Calculate displayed (filtered) model keys + const displayedModelKeys = useMemo(() => { + const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); + const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType); + return filteredModels.map((m) => m.key); + }, [data, searchTerm, filteredModelType]); + + // Calculate checkbox state + const { allSelected, someSelected } = useMemo(() => { + if (displayedModelKeys.length === 0) { + return { allSelected: false, someSelected: false }; + } + const selectedSet = new Set(selectedModelKeys); + const displayedSelectedCount = displayedModelKeys.filter((key) => selectedSet.has(key)).length; + return { + allSelected: displayedSelectedCount === displayedModelKeys.length, + someSelected: displayedSelectedCount > 0 && displayedSelectedCount < displayedModelKeys.length, + }; + }, [displayedModelKeys, selectedModelKeys]); const handleSearch: ChangeEventHandler = useCallback( (event) => { @@ -23,28 +55,56 @@ export const ModelListNavigation = memo(() => { dispatch(setSearchTerm('')); }, [dispatch]); + const handleToggleAll = useCallback(() => { + if (allSelected) { + // Deselect all displayed models + const displayedSet = new Set(displayedModelKeys); + const newSelection = selectedModelKeys.filter((key) => !displayedSet.has(key)); + dispatch(modelSelectionChanged(newSelection)); + } else { + // Select all displayed models (merge with existing selection) + const selectedSet = new Set(selectedModelKeys); + displayedModelKeys.forEach((key) => selectedSet.add(key)); + dispatch(modelSelectionChanged(Array.from(selectedSet))); + } + }, [allSelected, displayedModelKeys, selectedModelKeys, dispatch]); + return ( - - - - {!!searchTerm?.length && ( - - } - onClick={clearSearch} - /> - - )} - + + + + + {t('modelManager.selectAll')} + + + + + + {!!searchTerm?.length && ( + + } + onClick={clearSearch} + /> + + )} + + @@ -53,3 +113,34 @@ export const ModelListNavigation = memo(() => { }); ModelListNavigation.displayName = 'ModelListNavigation'; + +const modelsFilter = ( + data: T[], + nameFilter: string, + filteredModelType: FilterableModelType | null +): T[] => { + return data.filter((model) => { + const matchesFilter = + model.name.toLowerCase().includes(nameFilter.toLowerCase()) || + model.base.toLowerCase().includes(nameFilter.toLowerCase()) || + model.type.toLowerCase().includes(nameFilter.toLowerCase()) || + model.description?.toLowerCase().includes(nameFilter.toLowerCase()) || + model.format.toLowerCase().includes(nameFilter.toLowerCase()); + + const matchesType = getMatchesType(model, filteredModelType); + + return matchesFilter && matchesType; + }); +}; + +const getMatchesType = (modelConfig: AnyModelConfig, filteredModelType: FilterableModelType | null): boolean => { + if (filteredModelType === 'refiner') { + return modelConfig.base === 'sdxl-refiner'; + } + + if (filteredModelType === 'main' && modelConfig.base === 'sdxl-refiner') { + return false; + } + + return filteredModelType ? modelConfig.type === filteredModelType : true; +}; From 469c85b9cbdbaac52e8ad5a6ce6f79dd4c8ee208 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 21 Nov 2025 05:34:59 +0100 Subject: [PATCH 7/7] fix the wrong path seperater from my windows system --- invokeai/frontend/web/src/services/api/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index fff7f1fec45..51b98714cae 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -13009,14 +13009,14 @@ export type components = { * Convert Cache Dir * Format: path * @description Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions). - * @default models\.convert_cache + * @default models/.convert_cache */ convert_cache_dir?: string; /** * Download Cache Dir * Format: path * @description Path to the directory that contains dynamically downloaded models. - * @default models\.download_cache + * @default models/.download_cache */ download_cache_dir?: string; /**