Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading