From 48f191ca7f64091fbee77d5b4412f1c037749932 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 8 Mar 2026 21:10:35 +0100 Subject: [PATCH 1/3] feat: add bulk reidentify action for models (#8951) Add a "Reidentify Models" bulk action to the model manager, allowing users to re-probe multiple models at once instead of one by one. - Backend: POST /api/v2/models/i/bulk_reidentify endpoint with partial failure handling (returns succeeded/failed lists) - Frontend: bulk reidentify mutation, confirmation modal with warning about custom settings reset, toast notifications for all outcomes - i18n: new translation keys for bulk reidentify UI strings --- invokeai/app/api/routers/model_manager.py | 72 ++++++++++++++++++ invokeai/frontend/web/public/locales/en.json | 9 +++ .../BulkReidentifyModelsModal.tsx | 70 ++++++++++++++++++ .../subpanels/ModelManagerPanel/ModelList.tsx | 74 +++++++++++++++++++ .../ModelListBulkActions.tsx | 15 +++- .../web/src/services/api/endpoints/models.ts | 19 +++++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkReidentifyModelsModal.tsx diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 234c6c96629..50cc03a9ba4 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -497,6 +497,19 @@ class BulkDeleteModelsResponse(BaseModel): failed: List[dict] = Field(description="List of failed deletions with error messages") +class BulkReidentifyModelsRequest(BaseModel): + """Request body for bulk model reidentification.""" + + keys: List[str] = Field(description="List of model keys to reidentify") + + +class BulkReidentifyModelsResponse(BaseModel): + """Response body for bulk model reidentification.""" + + succeeded: List[str] = Field(description="List of successfully reidentified model keys") + failed: List[dict] = Field(description="List of failed reidentifications with error messages") + + @model_manager_router.post( "/i/bulk_delete", operation_id="bulk_delete_models", @@ -538,6 +551,65 @@ async def bulk_delete_models( return BulkDeleteModelsResponse(deleted=deleted, failed=failed) +@model_manager_router.post( + "/i/bulk_reidentify", + operation_id="bulk_reidentify_models", + responses={ + 200: {"description": "Models reidentified (possibly with some failures)"}, + }, + status_code=200, +) +async def bulk_reidentify_models( + current_admin: AdminUserOrDefault, + request: BulkReidentifyModelsRequest = Body(description="List of model keys to reidentify"), +) -> BulkReidentifyModelsResponse: + """ + Reidentify multiple models by re-probing their weights files. + + Returns a list of successfully reidentified keys and failed reidentifications with error messages. + """ + logger = ApiDependencies.invoker.services.logger + store = ApiDependencies.invoker.services.model_manager.store + models_path = ApiDependencies.invoker.services.configuration.models_path + + succeeded = [] + failed = [] + + for key in request.keys: + try: + config = store.get_model(key) + if pathlib.Path(config.path).is_relative_to(models_path): + model_path = pathlib.Path(config.path) + else: + model_path = models_path / config.path + mod = ModelOnDisk(model_path) + result = ModelConfigFactory.from_model_on_disk(mod) + if result.config is None: + raise InvalidModelException("Unable to identify model format") + + # Retain user-editable fields from the original config + result.config.key = config.key + result.config.name = config.name + result.config.description = config.description + result.config.cover_image = config.cover_image + result.config.trigger_phrases = config.trigger_phrases + result.config.source = config.source + result.config.source_type = config.source_type + + store.replace_model(config.key, result.config) + succeeded.append(key) + logger.info(f"Reidentified model: {key}") + except UnknownModelException as e: + logger.error(f"Failed to reidentify model {key}: {str(e)}") + failed.append({"key": key, "error": str(e)}) + except Exception as e: + logger.error(f"Failed to reidentify model {key}: {str(e)}") + failed.append({"key": key, "error": str(e)}) + + logger.info(f"Bulk reidentify completed: {len(succeeded)} succeeded, {len(failed)} failed") + return BulkReidentifyModelsResponse(succeeded=succeeded, failed=failed) + + @model_manager_router.delete( "/i/{key}/image", operation_id="delete_model_image", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2db971d06a6..d4d0f1517ec 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -955,6 +955,15 @@ "reidentifySuccess": "Model reidentified successfully", "reidentifyUnknown": "Unable to identify model", "reidentifyError": "Error reidentifying model", + "reidentifyModels": "Reidentify Models", + "reidentifyModelsConfirm": "Are you sure you want to reidentify {{count}} model(s)? This will re-probe their weights files to determine the correct format and settings.", + "reidentifyWarning": "This will reset any custom settings you may have applied to these models.", + "modelsReidentified": "Successfully reidentified {{count}} model(s)", + "modelsReidentifyFailed": "Failed to reidentify models", + "someModelsFailedToReidentify": "{{count}} model(s) could not be reidentified", + "modelsReidentifiedPartial": "Partially completed", + "someModelsReidentified": "{{succeeded}} reidentified, {{failed}} failed", + "modelsReidentifyError": "Error reidentifying models", "updatePath": "Update Path", "updatePathTooltip": "Update the file path for this model if you have moved the model files to a new location.", "updatePathDescription": "Enter the new path to the model file or directory. Use this if you have manually moved the model files on disk.", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkReidentifyModelsModal.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkReidentifyModelsModal.tsx new file mode 100644 index 00000000000..2d73b81687c --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/BulkReidentifyModelsModal.tsx @@ -0,0 +1,70 @@ +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 BulkReidentifyModelsModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + modelCount: number; + isReidentifying?: boolean; +}; + +export const BulkReidentifyModelsModal = memo( + ({ isOpen, onClose, onConfirm, modelCount, isReidentifying = false }: BulkReidentifyModelsModalProps) => { + const { t } = useTranslation(); + const cancelRef = useRef(null); + + return ( + + + + + {t('modelManager.reidentifyModels', { + count: modelCount, + defaultValue: 'Reidentify Models', + })} + + + + + + {t('modelManager.reidentifyModelsConfirm', { + count: modelCount, + defaultValue: `Are you sure you want to reidentify ${modelCount} model(s)? This will re-probe their weights files to determine the correct format and settings.`, + })} + + + {t('modelManager.reidentifyWarning', { + defaultValue: 'This will reset any custom settings you may have applied to these models.', + })} + + + + + + + + + + + + ); + } +); + +BulkReidentifyModelsModal.displayName = 'BulkReidentifyModelsModal'; 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 f3be0b4686c..efb5c1add22 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -18,12 +18,14 @@ import { serializeError } from 'serialize-error'; import { modelConfigsAdapterSelectors, useBulkDeleteModelsMutation, + useBulkReidentifyModelsMutation, useGetMissingModelsQuery, useGetModelConfigsQuery, } from 'services/api/endpoints/models'; import type { AnyModelConfig } from 'services/api/types'; import { BulkDeleteModelsModal } from './BulkDeleteModelsModal'; +import { BulkReidentifyModelsModal } from './BulkReidentifyModelsModal'; import { FetchingModelsLoader } from './FetchingModelsLoader'; import { MissingModelsProvider } from './MissingModelsContext'; import { ModelListWrapper } from './ModelListWrapper'; @@ -31,6 +33,7 @@ import { ModelListWrapper } from './ModelListWrapper'; const log = logger('models'); export const [useBulkDeleteModal] = buildUseDisclosure(false); +export const [useBulkReidentifyModal] = buildUseDisclosure(false); const ModelList = () => { const dispatch = useAppDispatch(); @@ -40,11 +43,14 @@ const ModelList = () => { const { t } = useTranslation(); const toast = useToast(); const { isOpen, close } = useBulkDeleteModal(); + const { isOpen: isReidentifyOpen, close: closeReidentify } = useBulkReidentifyModal(); const [isDeleting, setIsDeleting] = useState(false); + const [isReidentifying, setIsReidentifying] = useState(false); const { data: allModelsData, isLoading: isLoadingAll } = useGetModelConfigsQuery(); const { data: missingModelsData, isLoading: isLoadingMissing } = useGetMissingModelsQuery(); const [bulkDeleteModels] = useBulkDeleteModelsMutation(); + const [bulkReidentifyModels] = useBulkReidentifyModelsMutation(); const data = filteredModelType === 'missing' ? missingModelsData : allModelsData; const isLoading = filteredModelType === 'missing' ? isLoadingMissing : isLoadingAll; @@ -148,6 +154,67 @@ const ModelList = () => { } }, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]); + const handleConfirmBulkReidentify = useCallback(async () => { + setIsReidentifying(true); + try { + const result = await bulkReidentifyModels({ keys: selectedModelKeys }).unwrap(); + + // Clear selection and close modal + dispatch(clearModelSelection()); + dispatch(setSelectedModelKey(null)); + closeReidentify(); + + if (result.failed.length === 0) { + toast({ + id: 'BULK_REIDENTIFY_SUCCESS', + title: t('modelManager.modelsReidentified', { + count: result.succeeded.length, + defaultValue: `Successfully reidentified ${result.succeeded.length} model(s)`, + }), + status: 'success', + }); + } else if (result.succeeded.length === 0) { + toast({ + id: 'BULK_REIDENTIFY_FAILED', + title: t('modelManager.modelsReidentifyFailed', { + defaultValue: 'Failed to reidentify models', + }), + description: t('modelManager.someModelsFailedToReidentify', { + count: result.failed.length, + defaultValue: `${result.failed.length} model(s) could not be reidentified`, + }), + status: 'error', + }); + } else { + toast({ + id: 'BULK_REIDENTIFY_PARTIAL', + title: t('modelManager.modelsReidentifiedPartial', { + defaultValue: 'Partially completed', + }), + description: t('modelManager.someModelsReidentified', { + succeeded: result.succeeded.length, + failed: result.failed.length, + defaultValue: `${result.succeeded.length} reidentified, ${result.failed.length} failed`, + }), + status: 'warning', + }); + } + + log.info(`Bulk reidentify completed: ${result.succeeded.length} succeeded, ${result.failed.length} failed`); + } catch (err) { + log.error({ error: serializeError(err as Error) }, 'Bulk reidentify error'); + toast({ + id: 'BULK_REIDENTIFY_ERROR', + title: t('modelManager.modelsReidentifyError', { + defaultValue: 'Error reidentifying models', + }), + status: 'error', + }); + } finally { + setIsReidentifying(false); + } + }, [bulkReidentifyModels, selectedModelKeys, dispatch, closeReidentify, toast, t]); + return ( @@ -173,6 +240,13 @@ const ModelList = () => { modelCount={selectedModelKeys.length} isDeleting={isDeleting} /> + ); }; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx index 57e970b48d9..87995a0cd2d 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx @@ -11,7 +11,7 @@ import { } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { t } from 'i18next'; import { memo, useCallback, useMemo } from 'react'; -import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi'; +import { PiCaretDownBold, PiSparkleFill, PiTrashSimpleBold } from 'react-icons/pi'; import { modelConfigsAdapterSelectors, useGetMissingModelsQuery, @@ -19,7 +19,7 @@ import { } from 'services/api/endpoints/models'; import type { AnyModelConfig } from 'services/api/types'; -import { useBulkDeleteModal } from './ModelList'; +import { useBulkDeleteModal, useBulkReidentifyModal } from './ModelList'; const ModelListBulkActionsSx: SystemStyleObject = { alignItems: 'center', @@ -40,11 +40,16 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => const { data: allModelsData } = useGetModelConfigsQuery(); const { data: missingModelsData } = useGetMissingModelsQuery(); const bulkDeleteModal = useBulkDeleteModal(); + const bulkReidentifyModal = useBulkReidentifyModal(); const handleBulkDelete = useCallback(() => { bulkDeleteModal.open(); }, [bulkDeleteModal]); + const handleBulkReidentify = useCallback(() => { + bulkReidentifyModal.open(); + }, [bulkReidentifyModal]); + // Calculate displayed (filtered) model keys const displayedModelKeys = useMemo(() => { // Use missing models data when the filter is 'missing' @@ -125,6 +130,12 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => {t('modelManager.actions')} + } onClick={handleBulkReidentify}> + {t('modelManager.reidentifyModels', { + count: selectionCount, + defaultValue: 'Reidentify Models', + })} + } onClick={handleBulkDelete} color="error.300"> {t('modelManager.deleteModels', { count: selectionCount })} diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index f48b5867672..84d2d1b2a5e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -52,6 +52,14 @@ type BulkDeleteModelsResponse = { failed: string[]; }; +type BulkReidentifyModelsArg = { + keys: string[]; +}; +type BulkReidentifyModelsResponse = { + succeeded: string[]; + failed: string[]; +}; + type ConvertMainModelResponse = paths['/api/v2/models/convert/{key}']['put']['responses']['200']['content']['application/json']; @@ -419,6 +427,16 @@ export const modelsApi = api.injectEndpoints({ } }, }), + bulkReidentifyModels: build.mutation({ + query: ({ keys }) => { + return { + url: buildModelsUrl('i/bulk_reidentify'), + method: 'POST', + body: { keys }, + }; + }, + invalidatesTags: [{ type: 'ModelConfig', id: LIST_TAG }], + }), getOrphanedModels: build.query({ query: () => ({ url: buildModelsUrl('sync/orphaned'), @@ -463,6 +481,7 @@ export const { useResetHFTokenMutation, useEmptyModelCacheMutation, useReidentifyModelMutation, + useBulkReidentifyModelsMutation, useGetOrphanedModelsQuery, useDeleteOrphanedModelsMutation, } = modelsApi; From d1780f2dd7a4899f6ff582b14852f97941a137e6 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 8 Mar 2026 21:27:05 +0100 Subject: [PATCH 2/3] fix typgen --- .../frontend/web/src/services/api/schema.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b605413787b..74f7a757450 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -362,6 +362,28 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/models/i/bulk_reidentify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Reidentify Models + * @description Reidentify multiple models by re-probing their weights files. + * + * Returns a list of successfully reidentified keys and failed reidentifications with error messages. + */ + post: operations["bulk_reidentify_models"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/install": { parameters: { query?: never; @@ -3516,6 +3538,35 @@ export type components = { */ bulk_download_item_name: string; }; + /** + * BulkReidentifyModelsRequest + * @description Request body for bulk model reidentification. + */ + BulkReidentifyModelsRequest: { + /** + * Keys + * @description List of model keys to reidentify + */ + keys: string[]; + }; + /** + * BulkReidentifyModelsResponse + * @description Response body for bulk model reidentification. + */ + BulkReidentifyModelsResponse: { + /** + * Succeeded + * @description List of successfully reidentified model keys + */ + succeeded: string[]; + /** + * Failed + * @description List of failed reidentifications with error messages + */ + failed: { + [key: string]: unknown; + }[]; + }; /** CLIPEmbed_Diffusers_G_Config */ CLIPEmbed_Diffusers_G_Config: { /** @@ -29155,6 +29206,39 @@ export interface operations { }; }; }; + bulk_reidentify_models: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkReidentifyModelsRequest"]; + }; + }; + responses: { + /** @description Models reidentified (possibly with some failures) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkReidentifyModelsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_model_installs: { parameters: { query?: never; From 39ff53fdf7448d62778f5abc3beaa8ff5ddd7be3 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 29 Mar 2026 05:06:46 +0200 Subject: [PATCH 3/3] Fix bulk reidentify failing for models without trigger_phrases The bulk reidentify endpoint was directly assigning trigger_phrases without checking if the config type supports it, causing an AttributeError for ControlNet models. Added the same hasattr guard used by the individual reidentify endpoint. Also restored the missing path preservation that the individual endpoint has. --- invokeai/app/api/routers/model_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index a7700f22a47..65b059ecfce 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -607,11 +607,13 @@ async def bulk_reidentify_models( raise InvalidModelException("Unable to identify model format") # Retain user-editable fields from the original config + result.config.path = config.path result.config.key = config.key result.config.name = config.name result.config.description = config.description result.config.cover_image = config.cover_image - result.config.trigger_phrases = config.trigger_phrases + if hasattr(config, "trigger_phrases") and hasattr(result.config, "trigger_phrases"): + result.config.trigger_phrases = config.trigger_phrases result.config.source = config.source result.config.source_type = config.source_type