diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 14b18aac7a6..a7700f22a47 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -516,6 +516,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", @@ -557,6 +570,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 58be5430a26..89bbb38bdf8 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1012,6 +1012,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 567d63a1000..c3d0decd53c 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']; @@ -431,6 +439,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'), @@ -475,6 +493,7 @@ export const { useResetHFTokenMutation, useEmptyModelCacheMutation, useReidentifyModelMutation, + useBulkReidentifyModelsMutation, useGetOrphanedModelsQuery, useDeleteOrphanedModelsMutation, } = modelsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index fc6506ce22b..600878a4338 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -521,6 +521,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; @@ -3728,6 +3750,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: { /** @@ -29647,6 +29698,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;