Skip to content
53 changes: 53 additions & 0 deletions invokeai/app/api/routers/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof zModelManagerState>;
Expand All @@ -30,6 +31,7 @@ const getInitialState = (): ModelManagerState => ({
searchTerm: '',
scanPath: undefined,
shouldInstallInPlace: true,
selectedModelKeys: [],
});

const slice = createSlice({
Expand All @@ -55,6 +57,20 @@ const slice = createSlice({
shouldInstallInPlaceChanged: (state, action: PayloadAction<boolean>) => {
state.shouldInstallInPlace = action.payload;
},
modelSelectionChanged: (state, action: PayloadAction<string[]>) => {
state.selectedModelKeys = action.payload;
},
toggleModelSelection: (state, action: PayloadAction<string>) => {
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 = [];
},
},
});

Expand All @@ -65,6 +81,9 @@ export const {
setSelectedModelMode,
setScanPath,
shouldInstallInPlaceChanged,
modelSelectionChanged,
toggleModelSelection,
clearModelSelection,
} = slice.actions;

export const modelManagerSliceConfig: SliceConfig<typeof slice> = {
Expand All @@ -79,7 +98,7 @@ export const modelManagerSliceConfig: SliceConfig<typeof slice> = {
}
return zModelManagerState.parse(state);
},
persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm'],
persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm', 'selectedModelKeys'],
},
};

Expand All @@ -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);
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);

return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('modelManager.deleteModels', { count: modelCount })}
</AlertDialogHeader>

<AlertDialogBody>
<Flex flexDir="column" gap={3}>
<Text>
{t('modelManager.deleteModelsConfirm', {
count: modelCount,
defaultValue: `Are you sure you want to delete ${modelCount} model(s)? This action cannot be undone.`,
})}
</Text>
<Text fontWeight="semibold" color="error.400">
{t('modelManager.deleteWarning', {
defaultValue: 'Models in your Invoke models directory will be permanently deleted from disk.',
})}
</Text>
</Flex>
</AlertDialogBody>

<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} isDisabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button colorScheme="error" onClick={onConfirm} ml={3} isLoading={isDeleting}>
{t('common.delete')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
}
);

BulkDeleteModelsModal.displayName = 'BulkDeleteModelsModal';
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
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 { serializeError } from 'serialize-error';
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: {} });
Expand All @@ -46,20 +62,99 @@ 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 (err) {
log.error({ error: serializeError(err as Error) }, 'Bulk delete 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]);

return (
<ScrollableContent>
<Flex flexDirection="column" w="full" h="full" gap={4}>
{isLoading && <FetchingModelsLoader loadingMessage="Loading..." />}
{models.byCategory.map(({ i18nKey, configs }) => (
<ModelListWrapper key={i18nKey} title={t(i18nKey)} modelList={configs} />
))}
{!isLoading && models.total === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text>{t('modelManager.noMatchingModels')}</Text>
<>
<Flex flexDirection="column" w="full" h="full">
<ModelListHeader onBulkDelete={handleBulkDelete} />
<ScrollableContent>
<Flex flexDirection="column" w="full" h="full" gap={4}>
{isLoading && <FetchingModelsLoader loadingMessage="Loading..." />}
{models.byCategory.map(({ i18nKey, configs }) => (
<ModelListWrapper key={i18nKey} title={t(i18nKey)} modelList={configs} />
))}
{!isLoading && models.total === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text>{t('modelManager.noMatchingModels')}</Text>
</Flex>
)}
</Flex>
)}
</ScrollableContent>
</Flex>
</ScrollableContent>
<BulkDeleteModelsModal
isOpen={isOpen}
onClose={onClose}
onConfirm={handleConfirmBulkDelete}
modelCount={selectedModelKeys.length}
isDeleting={isDeleting}
/>
</>
);
};

Expand Down
Loading