Skip to content

Commit 6f9f8e5

Browse files
Feature(UI): bulk remove models loras (#8659)
* 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. * fix: prevent model list header from scrolling with content * 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 * 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 * refactor: consolidate Button component props to single line * 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 * fix the wrong path seperater from my windows system --------- Co-authored-by: Claude <[email protected]>
1 parent c6a9847 commit 6f9f8e5

File tree

9 files changed

+583
-44
lines changed

9 files changed

+583
-44
lines changed

invokeai/app/api/routers/model_manager.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,59 @@ async def delete_model(
447447
raise HTTPException(status_code=404, detail=str(e))
448448

449449

450+
class BulkDeleteModelsRequest(BaseModel):
451+
"""Request body for bulk model deletion."""
452+
453+
keys: List[str] = Field(description="List of model keys to delete")
454+
455+
456+
class BulkDeleteModelsResponse(BaseModel):
457+
"""Response body for bulk model deletion."""
458+
459+
deleted: List[str] = Field(description="List of successfully deleted model keys")
460+
failed: List[dict] = Field(description="List of failed deletions with error messages")
461+
462+
463+
@model_manager_router.post(
464+
"/i/bulk_delete",
465+
operation_id="bulk_delete_models",
466+
responses={
467+
200: {"description": "Models deleted (possibly with some failures)"},
468+
},
469+
status_code=200,
470+
)
471+
async def bulk_delete_models(
472+
request: BulkDeleteModelsRequest = Body(description="List of model keys to delete"),
473+
) -> BulkDeleteModelsResponse:
474+
"""
475+
Delete multiple model records from database.
476+
477+
The configuration records will be removed. The corresponding weights files will be
478+
deleted as well if they reside within the InvokeAI "models" directory.
479+
Returns a list of successfully deleted keys and failed deletions with error messages.
480+
"""
481+
logger = ApiDependencies.invoker.services.logger
482+
installer = ApiDependencies.invoker.services.model_manager.install
483+
484+
deleted = []
485+
failed = []
486+
487+
for key in request.keys:
488+
try:
489+
installer.delete(key)
490+
deleted.append(key)
491+
logger.info(f"Deleted model: {key}")
492+
except UnknownModelException as e:
493+
logger.error(f"Failed to delete model {key}: {str(e)}")
494+
failed.append({"key": key, "error": str(e)})
495+
except Exception as e:
496+
logger.error(f"Failed to delete model {key}: {str(e)}")
497+
failed.append({"key": key, "error": str(e)})
498+
499+
logger.info(f"Bulk delete completed: {len(deleted)} deleted, {len(failed)} failed")
500+
return BulkDeleteModelsResponse(deleted=deleted, failed=failed)
501+
502+
450503
@model_manager_router.delete(
451504
"/i/{key}/image",
452505
operation_id="delete_model_image",

invokeai/frontend/web/src/features/modelManagerV2/store/modelManagerV2Slice.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const zModelManagerState = z.object({
1818
filteredModelType: zFilterableModelType.nullable(),
1919
scanPath: z.string().optional(),
2020
shouldInstallInPlace: z.boolean(),
21+
selectedModelKeys: z.array(z.string()),
2122
});
2223

2324
type ModelManagerState = z.infer<typeof zModelManagerState>;
@@ -30,6 +31,7 @@ const getInitialState = (): ModelManagerState => ({
3031
searchTerm: '',
3132
scanPath: undefined,
3233
shouldInstallInPlace: true,
34+
selectedModelKeys: [],
3335
});
3436

3537
const slice = createSlice({
@@ -55,6 +57,20 @@ const slice = createSlice({
5557
shouldInstallInPlaceChanged: (state, action: PayloadAction<boolean>) => {
5658
state.shouldInstallInPlace = action.payload;
5759
},
60+
modelSelectionChanged: (state, action: PayloadAction<string[]>) => {
61+
state.selectedModelKeys = action.payload;
62+
},
63+
toggleModelSelection: (state, action: PayloadAction<string>) => {
64+
const index = state.selectedModelKeys.indexOf(action.payload);
65+
if (index > -1) {
66+
state.selectedModelKeys.splice(index, 1);
67+
} else {
68+
state.selectedModelKeys.push(action.payload);
69+
}
70+
},
71+
clearModelSelection: (state) => {
72+
state.selectedModelKeys = [];
73+
},
5874
},
5975
});
6076

@@ -65,6 +81,9 @@ export const {
6581
setSelectedModelMode,
6682
setScanPath,
6783
shouldInstallInPlaceChanged,
84+
modelSelectionChanged,
85+
toggleModelSelection,
86+
clearModelSelection,
6887
} = slice.actions;
6988

7089
export const modelManagerSliceConfig: SliceConfig<typeof slice> = {
@@ -79,7 +98,7 @@ export const modelManagerSliceConfig: SliceConfig<typeof slice> = {
7998
}
8099
return zModelManagerState.parse(state);
81100
},
82-
persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm'],
101+
persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm', 'selectedModelKeys'],
83102
},
84103
};
85104

@@ -93,3 +112,4 @@ export const selectSelectedModelMode = createModelManagerSelector((modelManager)
93112
export const selectSearchTerm = createModelManagerSelector((mm) => mm.searchTerm);
94113
export const selectFilteredModelType = createModelManagerSelector((mm) => mm.filteredModelType);
95114
export const selectShouldInstallInPlace = createModelManagerSelector((mm) => mm.shouldInstallInPlace);
115+
export const selectSelectedModelKeys = createModelManagerSelector((mm) => mm.selectedModelKeys);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
AlertDialog,
3+
AlertDialogBody,
4+
AlertDialogContent,
5+
AlertDialogFooter,
6+
AlertDialogHeader,
7+
AlertDialogOverlay,
8+
Button,
9+
Flex,
10+
Text,
11+
} from '@invoke-ai/ui-library';
12+
import { memo, useRef } from 'react';
13+
import { useTranslation } from 'react-i18next';
14+
15+
type BulkDeleteModelsModalProps = {
16+
isOpen: boolean;
17+
onClose: () => void;
18+
onConfirm: () => void;
19+
modelCount: number;
20+
isDeleting?: boolean;
21+
};
22+
23+
export const BulkDeleteModelsModal = memo(
24+
({ isOpen, onClose, onConfirm, modelCount, isDeleting = false }: BulkDeleteModelsModalProps) => {
25+
const { t } = useTranslation();
26+
const cancelRef = useRef<HTMLButtonElement>(null);
27+
28+
return (
29+
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered>
30+
<AlertDialogOverlay>
31+
<AlertDialogContent>
32+
<AlertDialogHeader fontSize="lg" fontWeight="bold">
33+
{t('modelManager.deleteModels', { count: modelCount })}
34+
</AlertDialogHeader>
35+
36+
<AlertDialogBody>
37+
<Flex flexDir="column" gap={3}>
38+
<Text>
39+
{t('modelManager.deleteModelsConfirm', {
40+
count: modelCount,
41+
defaultValue: `Are you sure you want to delete ${modelCount} model(s)? This action cannot be undone.`,
42+
})}
43+
</Text>
44+
<Text fontWeight="semibold" color="error.400">
45+
{t('modelManager.deleteWarning', {
46+
defaultValue: 'Models in your Invoke models directory will be permanently deleted from disk.',
47+
})}
48+
</Text>
49+
</Flex>
50+
</AlertDialogBody>
51+
52+
<AlertDialogFooter>
53+
<Button ref={cancelRef} onClick={onClose} isDisabled={isDeleting}>
54+
{t('common.cancel')}
55+
</Button>
56+
<Button colorScheme="error" onClick={onConfirm} ml={3} isLoading={isDeleting}>
57+
{t('common.delete')}
58+
</Button>
59+
</AlertDialogFooter>
60+
</AlertDialogContent>
61+
</AlertDialogOverlay>
62+
</AlertDialog>
63+
);
64+
}
65+
);
66+
67+
BulkDeleteModelsModal.displayName = 'BulkDeleteModelsModal';

invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx

Lines changed: 110 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,45 @@
1-
import { Flex, Text } from '@invoke-ai/ui-library';
1+
import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library';
22
import { logger } from 'app/logging/logger';
3-
import { useAppSelector } from 'app/store/storeHooks';
3+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
44
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
55
import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models';
66
import {
7+
clearModelSelection,
78
type FilterableModelType,
89
selectFilteredModelType,
910
selectSearchTerm,
11+
selectSelectedModelKeys,
12+
setSelectedModelKey,
1013
} from 'features/modelManagerV2/store/modelManagerV2Slice';
11-
import { memo, useMemo } from 'react';
14+
import { memo, useCallback, useMemo, useState } from 'react';
1215
import { useTranslation } from 'react-i18next';
13-
import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
16+
import { serializeError } from 'serialize-error';
17+
import {
18+
modelConfigsAdapterSelectors,
19+
useBulkDeleteModelsMutation,
20+
useGetModelConfigsQuery,
21+
} from 'services/api/endpoints/models';
1422
import type { AnyModelConfig } from 'services/api/types';
1523

24+
import { BulkDeleteModelsModal } from './BulkDeleteModelsModal';
1625
import { FetchingModelsLoader } from './FetchingModelsLoader';
26+
import { ModelListHeader } from './ModelListHeader';
1727
import { ModelListWrapper } from './ModelListWrapper';
1828

1929
const log = logger('models');
2030

2131
const ModelList = () => {
32+
const dispatch = useAppDispatch();
2233
const filteredModelType = useAppSelector(selectFilteredModelType);
2334
const searchTerm = useAppSelector(selectSearchTerm);
35+
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
2436
const { t } = useTranslation();
37+
const toast = useToast();
38+
const { isOpen, onOpen, onClose } = useDisclosure();
39+
const [isDeleting, setIsDeleting] = useState(false);
2540

2641
const { data, isLoading } = useGetModelConfigsQuery();
42+
const [bulkDeleteModels] = useBulkDeleteModelsMutation();
2743

2844
const models = useMemo(() => {
2945
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
@@ -46,20 +62,99 @@ const ModelList = () => {
4662
return { total, byCategory };
4763
}, [data, filteredModelType, searchTerm]);
4864

65+
const handleBulkDelete = useCallback(() => {
66+
onOpen();
67+
}, [onOpen]);
68+
69+
const handleConfirmBulkDelete = useCallback(async () => {
70+
setIsDeleting(true);
71+
try {
72+
const result = await bulkDeleteModels({ keys: selectedModelKeys }).unwrap();
73+
74+
// Clear selection and close modal
75+
dispatch(clearModelSelection());
76+
dispatch(setSelectedModelKey(null));
77+
onClose();
78+
79+
// Show success/failure toast
80+
if (result.failed.length === 0) {
81+
toast({
82+
id: 'BULK_DELETE_SUCCESS',
83+
title: t('modelManager.modelsDeleted', {
84+
count: result.deleted.length,
85+
defaultValue: `Successfully deleted ${result.deleted.length} model(s)`,
86+
}),
87+
status: 'success',
88+
});
89+
} else if (result.deleted.length === 0) {
90+
toast({
91+
id: 'BULK_DELETE_FAILED',
92+
title: t('modelManager.modelsDeleteFailed', {
93+
defaultValue: 'Failed to delete models',
94+
}),
95+
description: t('modelManager.someModelsFailedToDelete', {
96+
count: result.failed.length,
97+
defaultValue: `${result.failed.length} model(s) could not be deleted`,
98+
}),
99+
status: 'error',
100+
});
101+
} else {
102+
// Partial success
103+
toast({
104+
id: 'BULK_DELETE_PARTIAL',
105+
title: t('modelManager.modelsDeletedPartial', {
106+
defaultValue: 'Partially completed',
107+
}),
108+
description: t('modelManager.someModelsDeleted', {
109+
deleted: result.deleted.length,
110+
failed: result.failed.length,
111+
defaultValue: `${result.deleted.length} deleted, ${result.failed.length} failed`,
112+
}),
113+
status: 'warning',
114+
});
115+
}
116+
117+
log.info(`Bulk delete completed: ${result.deleted.length} deleted, ${result.failed.length} failed`);
118+
} catch (err) {
119+
log.error({ error: serializeError(err as Error) }, 'Bulk delete error');
120+
toast({
121+
id: 'BULK_DELETE_ERROR',
122+
title: t('modelManager.modelsDeleteError', {
123+
defaultValue: 'Error deleting models',
124+
}),
125+
status: 'error',
126+
});
127+
} finally {
128+
setIsDeleting(false);
129+
}
130+
}, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t]);
131+
49132
return (
50-
<ScrollableContent>
51-
<Flex flexDirection="column" w="full" h="full" gap={4}>
52-
{isLoading && <FetchingModelsLoader loadingMessage="Loading..." />}
53-
{models.byCategory.map(({ i18nKey, configs }) => (
54-
<ModelListWrapper key={i18nKey} title={t(i18nKey)} modelList={configs} />
55-
))}
56-
{!isLoading && models.total === 0 && (
57-
<Flex w="full" h="full" alignItems="center" justifyContent="center">
58-
<Text>{t('modelManager.noMatchingModels')}</Text>
133+
<>
134+
<Flex flexDirection="column" w="full" h="full">
135+
<ModelListHeader onBulkDelete={handleBulkDelete} />
136+
<ScrollableContent>
137+
<Flex flexDirection="column" w="full" h="full" gap={4}>
138+
{isLoading && <FetchingModelsLoader loadingMessage="Loading..." />}
139+
{models.byCategory.map(({ i18nKey, configs }) => (
140+
<ModelListWrapper key={i18nKey} title={t(i18nKey)} modelList={configs} />
141+
))}
142+
{!isLoading && models.total === 0 && (
143+
<Flex w="full" h="full" alignItems="center" justifyContent="center">
144+
<Text>{t('modelManager.noMatchingModels')}</Text>
145+
</Flex>
146+
)}
59147
</Flex>
60-
)}
148+
</ScrollableContent>
61149
</Flex>
62-
</ScrollableContent>
150+
<BulkDeleteModelsModal
151+
isOpen={isOpen}
152+
onClose={onClose}
153+
onConfirm={handleConfirmBulkDelete}
154+
modelCount={selectedModelKeys.length}
155+
isDeleting={isDeleting}
156+
/>
157+
</>
63158
);
64159
};
65160

0 commit comments

Comments
 (0)