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..0bf496b583b 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,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: {} }); @@ -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 ( - - - {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..3d0d71029b8 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx @@ -0,0 +1,70 @@ +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 { PiCaretDownBold, 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')} + + + + + } flexShrink={0}> + {t('modelManager.actions')} + + + } onClick={onBulkDelete} color="error.300"> + {t('modelManager.deleteModels', { count: selectionCount })} + + + + + ); +}); + +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..d6dda98e80e 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,40 @@ const ModelListItem = ({ model }: ModelListItemProps) => { [model.key] ); const isSelected = useAppSelector(selectIsSelected); + const selectedModelKeys = useAppSelector(selectSelectedModelKeys); + const isChecked = selectedModelKeys.includes(model.key); - 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; + } + + // 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 { + dispatch(setSelectedModelKey(model.key)); + } + }, + [model.key, dispatch] + ); + + const handleCheckboxChange = useCallback(() => { + dispatch(toggleModelSelection(model.key)); }, [model.key, dispatch]); + const handleCheckboxClick = useCallback((e: MouseEvent) => { + e.stopPropagation(); + }, []); + return ( { cursor="pointer" onClick={handleSelectModel} > + 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; +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index 51b4a84bdc1..707352bcb39 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -43,6 +43,14 @@ type DeleteModelArg = { type DeleteModelResponse = void; type DeleteModelImageResponse = void; +type BulkDeleteModelsArg = { + keys: string[]; +}; +type BulkDeleteModelsResponse = { + deleted: string[]; + failed: string[]; +}; + type ConvertMainModelResponse = paths['/api/v2/models/convert/{key}']['put']['responses']['200']['content']['application/json']; @@ -151,6 +159,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 +358,7 @@ export const { useGetModelConfigsQuery, useGetModelConfigQuery, useDeleteModelsMutation, + useBulkDeleteModelsMutation, useDeleteModelImageMutation, useUpdateModelMutation, useUpdateModelImageMutation, diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index c52d6f1744c..51b98714cae 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 @@ -24959,6 +25012,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;