Skip to content
Merged
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
22 changes: 22 additions & 0 deletions invokeai/app/api/routers/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,28 @@ async def list_model_records(
return ModelsList(models=found_models)


@model_manager_router.get(
"/missing",
operation_id="list_missing_models",
responses={200: {"description": "List of models with missing files"}},
)
async def list_missing_models() -> ModelsList:
"""Get models whose files are missing from disk.

These are models that have database entries but their corresponding
weight files have been deleted externally (not via Model Manager).
"""
record_store = ApiDependencies.invoker.services.model_manager.store
models_path = ApiDependencies.invoker.services.configuration.models_path

missing_models: list[AnyModelConfig] = []
for model_config in record_store.all_models():
if not (models_path / model_config.path).resolve().exists():
missing_models.append(model_config)

return ModelsList(models=missing_models)


@model_manager_router.get(
"/get_by_attrs",
operation_id="get_model_records_by_attrs",
Expand Down
2 changes: 2 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,8 @@
"loraModels": "LoRAs",
"main": "Main",
"metadata": "Metadata",
"missingFiles": "Missing Files",
"missingFilesTooltip": "Model files are missing from disk",
"model": "Model",
"modelConversionFailed": "Model Conversion Failed",
"modelConverted": "Model Converted",
Expand Down
6 changes: 3 additions & 3 deletions invokeai/frontend/web/src/features/modelManagerV2/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ import {
} from 'services/api/types';
import { objectEntries } from 'tsafe';

import type { FilterableModelType } from './store/modelManagerV2Slice';
import type { ModelCategoryType } from './store/modelManagerV2Slice';

export type ModelCategoryData = {
category: FilterableModelType;
category: ModelCategoryType;
i18nKey: string;
filter: (config: AnyModelConfig) => boolean;
};

export const MODEL_CATEGORIES: Record<FilterableModelType, ModelCategoryData> = {
export const MODEL_CATEGORIES: Record<ModelCategoryType, ModelCategoryData> = {
unknown: {
category: 'unknown',
i18nKey: 'common.unknown',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { zModelType } from 'features/nodes/types/common';
import { assert } from 'tsafe';
import z from 'zod';

const zFilterableModelType = zModelType.exclude(['onnx']).or(z.literal('refiner'));
const zModelCategoryType = zModelType.exclude(['onnx']).or(z.literal('refiner'));
export type ModelCategoryType = z.infer<typeof zModelCategoryType>;

const zFilterableModelType = zModelCategoryType.or(z.literal('missing'));
export type FilterableModelType = z.infer<typeof zFilterableModelType>;

const zModelManagerState = z.object({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { PropsWithChildren } from 'react';
import { createContext, useContext, useMemo } from 'react';
import { modelConfigsAdapterSelectors, useGetMissingModelsQuery } from 'services/api/endpoints/models';

type MissingModelsContextValue = {
missingModelKeys: Set<string>;
isLoading: boolean;
};

const MissingModelsContext = createContext<MissingModelsContextValue>({
missingModelKeys: new Set(),
isLoading: false,
});

export const MissingModelsProvider = ({ children }: PropsWithChildren) => {
const { data, isLoading } = useGetMissingModelsQuery();

const value = useMemo(() => {
const missingModels = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
const missingModelKeys = new Set(missingModels.map((m) => m.key));
return { missingModelKeys, isLoading };
}, [data, isLoading]);

return <MissingModelsContext.Provider value={value}>{children}</MissingModelsContext.Provider>;
};

const useMissingModels = () => useContext(MissingModelsContext);

export const useIsModelMissing = (modelKey: string) => {
const { missingModelKeys } = useMissingModels();
return missingModelKeys.has(modelKey);
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import { serializeError } from 'serialize-error';
import {
modelConfigsAdapterSelectors,
useBulkDeleteModelsMutation,
useGetMissingModelsQuery,
useGetModelConfigsQuery,
} from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';

import { BulkDeleteModelsModal } from './BulkDeleteModelsModal';
import { FetchingModelsLoader } from './FetchingModelsLoader';
import { MissingModelsProvider } from './MissingModelsContext';
import { ModelListWrapper } from './ModelListWrapper';

const log = logger('models');
Expand All @@ -40,11 +42,30 @@ const ModelList = () => {
const { isOpen, close } = useBulkDeleteModal();
const [isDeleting, setIsDeleting] = useState(false);

const { data, isLoading } = useGetModelConfigsQuery();
const { data: allModelsData, isLoading: isLoadingAll } = useGetModelConfigsQuery();
const { data: missingModelsData, isLoading: isLoadingMissing } = useGetMissingModelsQuery();
const [bulkDeleteModels] = useBulkDeleteModelsMutation();

const data = filteredModelType === 'missing' ? missingModelsData : allModelsData;
const isLoading = filteredModelType === 'missing' ? isLoadingMissing : isLoadingAll;

const models = useMemo(() => {
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });

// For missing models filter, show all models in a single category
if (filteredModelType === 'missing') {
const filtered = modelConfigs.filter(
(m) =>
m.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.base.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.type.toLowerCase().includes(searchTerm.toLowerCase())
);
return {
total: filtered.length,
byCategory: [{ i18nKey: 'modelManager.missingFiles', configs: filtered }],
};
}

const baseFilteredModelConfigs = modelsFilter(modelConfigs, searchTerm, filteredModelType);
const byCategory: { i18nKey: string; configs: AnyModelConfig[] }[] = [];
const total = baseFilteredModelConfigs.length;
Expand Down Expand Up @@ -128,7 +149,7 @@ const ModelList = () => {
}, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]);

return (
<>
<MissingModelsProvider>
<Flex flexDirection="column" w="full" h="full">
<ScrollableContent>
<Flex flexDirection="column" w="full" h="full" gap={4}>
Expand All @@ -152,7 +173,7 @@ const ModelList = () => {
modelCount={selectedModelKeys.length}
isDeleting={isDeleting}
/>
</>
</MissingModelsProvider>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
import { t } from 'i18next';
import { memo, useCallback, useMemo } from 'react';
import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi';
import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
import {
modelConfigsAdapterSelectors,
useGetMissingModelsQuery,
useGetModelConfigsQuery,
} from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';

import { useBulkDeleteModal } from './ModelList';
Expand All @@ -31,7 +35,8 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) =>
const filteredModelType = useAppSelector(selectFilteredModelType);
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
const searchTerm = useAppSelector(selectSearchTerm);
const { data } = useGetModelConfigsQuery();
const { data: allModelsData } = useGetModelConfigsQuery();
const { data: missingModelsData } = useGetMissingModelsQuery();
const bulkDeleteModal = useBulkDeleteModal();

const handleBulkDelete = useCallback(() => {
Expand All @@ -40,10 +45,24 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) =>

// Calculate displayed (filtered) model keys
const displayedModelKeys = useMemo(() => {
// Use missing models data when the filter is 'missing'
const data = filteredModelType === 'missing' ? missingModelsData : allModelsData;
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });

// For missing models filter, only apply search term filter
if (filteredModelType === 'missing') {
const filtered = modelConfigs.filter(
(m) =>
m.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.base.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.type.toLowerCase().includes(searchTerm.toLowerCase())
);
return filtered.map((m) => m.key);
}

const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType);
return filteredModels.map((m) => m.key);
}, [data, searchTerm, filteredModelType]);
}, [allModelsData, missingModelsData, searchTerm, filteredModelType]);

const { allSelected, someSelected } = useMemo(() => {
if (displayedModelKeys.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { chakra, Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library';
import { Badge, chakra, Checkbox, Flex, Spacer, Text, Tooltip } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
Expand All @@ -15,8 +15,10 @@ import { filesize } from 'filesize';
import type { ChangeEvent, MouseEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiWarningBold } from 'react-icons/pi';
import type { AnyModelConfig } from 'services/api/types';

import { useIsModelMissing } from './MissingModelsContext';
import ModelImage from './ModelImage';

const StyledLabel = chakra('label');
Expand Down Expand Up @@ -58,6 +60,7 @@ const sx: SystemStyleObject = {
const ModelListItem = ({ model }: ModelListItemProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isMissing = useIsModelMissing(model.key);
const selectIsSelected = useMemo(
() =>
createSelector(
Expand Down Expand Up @@ -139,6 +142,14 @@ const ModelListItem = ({ model }: ModelListItemProps) => {
<Flex gap={1} mt={1}>
<ModelBaseBadge base={model.base} />
<ModelFormatBadge format={model.format} />
{isMissing && (
<Tooltip label={t('modelManager.missingFilesTooltip')}>
<Badge colorScheme="warning" display="flex" alignItems="center" gap={1}>
<PiWarningBold />
{t('modelManager.missingFiles')}
</Badge>
</Tooltip>
)}
</Flex>
</Flex>
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { Button, Flex, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { ModelCategoryData } from 'features/modelManagerV2/models';
import { MODEL_CATEGORIES, MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models';
import type { ModelCategoryType } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { selectFilteredModelType, setFilteredModelType } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFunnelBold } from 'react-icons/pi';
import { PiFunnelBold, PiWarningBold } from 'react-icons/pi';

const isModelCategoryType = (type: string): type is ModelCategoryType => {
return type in MODEL_CATEGORIES;
};

export const ModelTypeFilter = memo(() => {
const { t } = useTranslation();
Expand All @@ -16,13 +21,37 @@ export const ModelTypeFilter = memo(() => {
dispatch(setFilteredModelType(null));
}, [dispatch]);

const setMissingFilter = useCallback(() => {
dispatch(setFilteredModelType('missing'));
}, [dispatch]);

const getButtonLabel = () => {
if (filteredModelType === 'missing') {
return t('modelManager.missingFiles');
}
if (filteredModelType && isModelCategoryType(filteredModelType)) {
return t(MODEL_CATEGORIES[filteredModelType].i18nKey);
}
return t('modelManager.allModels');
};

return (
<Menu placement="bottom-end">
<MenuButton as={Button} size="sm" rightIcon={<PiFunnelBold />}>
{filteredModelType ? t(MODEL_CATEGORIES[filteredModelType].i18nKey) : t('modelManager.allModels')}
{getButtonLabel()}
</MenuButton>
<MenuList>
<MenuItem onClick={clearModelType}>{t('modelManager.allModels')}</MenuItem>
<MenuItem
onClick={setMissingFilter}
bg={filteredModelType === 'missing' ? 'base.700' : 'transparent'}
color="warning.300"
>
<Flex alignItems="center" gap={2}>
<PiWarningBold />
{t('modelManager.missingFiles')}
</Flex>
</MenuItem>
{MODEL_CATEGORIES_AS_LIST.map((data) => (
<ModelMenuItem key={data.category} data={data} />
))}
Expand Down
9 changes: 9 additions & 0 deletions invokeai/frontend/web/src/services/api/endpoints/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ export const modelsApi = api.injectEndpoints({
});
},
}),
getMissingModels: build.query<EntityState<AnyModelConfig, string>, void>({
query: () => ({ url: buildModelsUrl('missing') }),
providesTags: [{ type: 'ModelConfig', id: LIST_TAG }],
transformResponse: (response: GetModelConfigsResponse) => {
return modelConfigsAdapter.setAll(modelConfigsAdapter.getInitialState(), response.models);
},
}),
getStarterModels: build.query<GetStarterModelsResponse, void>({
query: () => buildModelsUrl('starter_models'),
providesTags: [{ type: 'ModelConfig', id: LIST_TAG }],
Expand Down Expand Up @@ -357,6 +364,7 @@ export const modelsApi = api.injectEndpoints({
export const {
useGetModelConfigsQuery,
useGetModelConfigQuery,
useGetMissingModelsQuery,
useDeleteModelsMutation,
useBulkDeleteModelsMutation,
useDeleteModelImageMutation,
Expand All @@ -378,3 +386,4 @@ export const {
} = modelsApi;

export const selectModelConfigsQuery = modelsApi.endpoints.getModelConfigs.select();
export const selectMissingModelsQuery = modelsApi.endpoints.getMissingModels.select();
24 changes: 22 additions & 2 deletions invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type { RootState } from 'app/store/store';
import { useMemo } from 'react';
import {
modelConfigsAdapterSelectors,
selectMissingModelsQuery,
selectModelConfigsQuery,
useGetMissingModelsQuery,
useGetModelConfigsQuery,
} from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';
Expand Down Expand Up @@ -33,16 +35,24 @@ const buildModelsHook =
<T extends AnyModelConfig>(typeGuard: (config: AnyModelConfig) => config is T) =>
(filter: (config: T) => boolean = () => true) => {
const result = useGetModelConfigsQuery(undefined);
const { data: missingModelsData } = useGetMissingModelsQuery();

const modelConfigs = useMemo(() => {
if (!result.data) {
return EMPTY_ARRAY;
}

// Get set of missing model keys to exclude from selection
const missingModelKeys = new Set(
modelConfigsAdapterSelectors.selectAll(missingModelsData ?? { ids: [], entities: {} }).map((m) => m.key)
);

return modelConfigsAdapterSelectors
.selectAll(result.data)
.filter((config) => typeGuard(config))
.filter((config) => !missingModelKeys.has(config.key))
.filter(filter);
}, [filter, result.data]);
}, [filter, result.data, missingModelsData]);

return [modelConfigs, result] as const;
};
Expand Down Expand Up @@ -75,7 +85,17 @@ const buildModelsSelector =
if (!result.data) {
return EMPTY_ARRAY;
}
return modelConfigsAdapterSelectors.selectAll(result.data).filter(typeGuard);

// Get set of missing model keys to exclude from selection
const missingResult = selectMissingModelsQuery(state);
const missingModelKeys = new Set(
modelConfigsAdapterSelectors.selectAll(missingResult.data ?? { ids: [], entities: {} }).map((m) => m.key)
);

return modelConfigsAdapterSelectors
.selectAll(result.data)
.filter(typeGuard)
.filter((config) => !missingModelKeys.has(config.key));
};
export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig);
export const selectGlobalRefImageModels = buildModelsSelector(
Expand Down
Loading