Model Manager Frontend Rebased

This commit is contained in:
blessedcoolant 2022-12-25 09:28:59 +13:00
parent 3521557541
commit 79d577bff9
26 changed files with 1218 additions and 219 deletions

View File

@ -27,6 +27,7 @@
"@types/uuid": "^8.3.4",
"add": "^2.0.6",
"dateformat": "^5.0.3",
"formik": "^2.2.9",
"framer-motion": "^7.2.1",
"i18next": "^22.4.5",
"i18next-browser-languagedetector": "^7.0.1",

View File

@ -0,0 +1,49 @@
{
"modelManager": "Model Manager",
"model": "Model",
"modelAdded": "Model Added",
"modelUpdated": "Model Updated",
"modelEntryDeleted": "Model Entry Deleted",
"cannotUseSpaces": "Cannot Use Spaces",
"addNew": "Add New",
"addNewModel": "Add New Model",
"addManually": "Add Manually",
"manual": "Manual",
"name": "Name",
"nameValidationMsg": "Enter a name for your model",
"description": "description",
"descriptionValidationMsg": "Add a description for your model",
"config": "Config",
"configValidationMsg": "Path to the config file of your model.",
"modelLocation": "Model Location",
"modelLocationValidationMsg": "Path to where your model is located.",
"vaeLocation": "VAE Location",
"vaeLocationValidationMsg": "Path to where your VAE is located.",
"width": "Width",
"widthValidationMsg": "Default width of your model.",
"height": "Height",
"heightValidationMsg": "Default height of your model.",
"addModel": "Add Model",
"availableModels": "Available Models",
"search": "Search",
"load": "Load",
"active": "active",
"notLoaded": "not loaded",
"cached": "cached",
"checkpointFolder": "Checkpoint Folder",
"clearCheckpointFolder": "Clear Checkpoint Folder",
"findModels": "Find Models",
"modelsFound": "Models Found",
"selectFolder": "Select Folder",
"selected": "Selected",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"showExisting": "Show Existing",
"addSelected": "Add Selected",
"modelExists": "Model Exists",
"delete": "Delete",
"deleteModel": "Delete Model",
"deleteConfig": "Delete Config",
"deleteMsg1": "Are you sure you want to delete this model entry from InvokeAI?",
"deleteMsg2": "This will not delete the model checkpoint file from your disk. You can readd them if you wish to."
}

View File

@ -164,10 +164,27 @@ export declare type ModelStatus = 'active' | 'cached' | 'not loaded';
export declare type Model = {
status: ModelStatus;
description: string;
weights: string;
};
export declare type ModelList = Record<string, Model>;
export declare type FoundModel = {
name: string;
location: string;
};
export declare type InvokeModelConfigProps = {
name: string;
description: string;
config: string;
weights: string;
vae: string;
width: number;
height: number;
default: boolean;
};
/**
* These types type data received from the server via socketio.
*/
@ -177,6 +194,22 @@ export declare type ModelChangeResponse = {
model_list: ModelList;
};
export declare type ModelAddedResponse = {
new_model_name: string;
model_list: ModelList;
update: boolean;
};
export declare type ModelDeletedResponse = {
deleted_model_name: string;
model_list: ModelList;
};
export declare type FoundModelResponse = {
search_folder: string;
found_models: FoundModel[];
};
export declare type SystemStatusResponse = SystemStatus;
export declare type SystemConfigResponse = SystemConfig;

View File

@ -30,6 +30,16 @@ export const requestSystemConfig = createAction<undefined>(
'socketio/requestSystemConfig'
);
export const searchForModels = createAction<undefined>(
'socketio/searchForModels'
);
export const addNewModel = createAction<InvokeAI.InvokeModelConfigProps>(
'socketio/addNewModel'
);
export const deleteModel = createAction<string>('socketio/deleteModel');
export const requestModelChange = createAction<string>(
'socketio/requestModelChange'
);

View File

@ -159,6 +159,15 @@ const makeSocketIOEmitters = (
emitRequestSystemConfig: () => {
socketio.emit('requestSystemConfig');
},
emitSearchForModels: () => {
socketio.emit('searchForModels');
},
emitAddNewModel: (modelConfig: InvokeAI.InvokeModelConfigProps) => {
socketio.emit('addNewModel', modelConfig);
},
emitDeleteModel: (modelName: string) => {
socketio.emit('deleteModel', modelName);
},
emitRequestModelChange: (modelName: string) => {
dispatch(modelChangeRequested());
socketio.emit('requestModelChange', modelName);

View File

@ -17,6 +17,8 @@ import {
setModelList,
setIsCancelable,
addToast,
setFoundModels,
setSearchFolder,
} from 'features/system/store/systemSlice';
import {
@ -351,6 +353,57 @@ const makeSocketIOListeners = (
dispatch(setInfillMethod(data.infill_methods[0]));
}
},
onFoundModels: (data: InvokeAI.FoundModelResponse) => {
const { search_folder, found_models } = data;
dispatch(setSearchFolder(search_folder));
dispatch(setFoundModels(found_models));
},
onNewModelAdded: (data: InvokeAI.ModelAddedResponse) => {
const { new_model_name, model_list, update } = data;
dispatch(setModelList(model_list));
dispatch(setIsProcessing(false));
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Model Added: ${new_model_name}`,
level: 'info',
})
);
dispatch(
addToast({
title: !update
? `${i18n.t('modelmanager:modelAdded')}: ${new_model_name}`
: `${i18n.t('modelmanager:modelUpdated')}: ${new_model_name}`,
status: 'success',
duration: 2500,
isClosable: true,
})
);
},
onModelDeleted: (data: InvokeAI.ModelDeletedResponse) => {
const { deleted_model_name, model_list } = data;
dispatch(setModelList(model_list));
dispatch(setIsProcessing(false));
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `${i18n.t(
'modelmanager:modelAdded'
)}: ${deleted_model_name}`,
level: 'info',
})
);
dispatch(
addToast({
title: `${i18n.t(
'modelmanager:modelEntryDeleted'
)}: ${deleted_model_name}`,
status: 'success',
duration: 2500,
isClosable: true,
})
);
},
onModelChanged: (data: InvokeAI.ModelChangeResponse) => {
const { model_name, model_list } = data;
dispatch(setModelList(model_list));

View File

@ -45,6 +45,9 @@ export const socketioMiddleware = () => {
onImageDeleted,
onSystemConfig,
onModelChanged,
onFoundModels,
onNewModelAdded,
onModelDeleted,
onModelChangeFailed,
onTempFolderEmptied,
} = makeSocketIOListeners(store);
@ -58,6 +61,9 @@ export const socketioMiddleware = () => {
emitRequestNewImages,
emitCancelProcessing,
emitRequestSystemConfig,
emitSearchForModels,
emitAddNewModel,
emitDeleteModel,
emitRequestModelChange,
emitSaveStagingAreaImageToGallery,
emitRequestEmptyTempFolder,
@ -107,6 +113,18 @@ export const socketioMiddleware = () => {
onSystemConfig(data);
});
socketio.on('foundModels', (data: InvokeAI.FoundModelResponse) => {
onFoundModels(data);
});
socketio.on('newModelAdded', (data: InvokeAI.ModelAddedResponse) => {
onNewModelAdded(data);
});
socketio.on('modelDeleted', (data: InvokeAI.ModelDeletedResponse) => {
onModelDeleted(data);
});
socketio.on('modelChanged', (data: InvokeAI.ModelChangeResponse) => {
onModelChanged(data);
});
@ -166,6 +184,21 @@ export const socketioMiddleware = () => {
break;
}
case 'socketio/searchForModels': {
emitSearchForModels();
break;
}
case 'socketio/addNewModel': {
emitAddNewModel(action.payload);
break;
}
case 'socketio/deleteModel': {
emitDeleteModel(action.payload);
break;
}
case 'socketio/requestModelChange': {
emitRequestModelChange(action.payload);
break;

View File

@ -1,7 +1,8 @@
import { Checkbox, CheckboxProps } from '@chakra-ui/react';
import type { ReactNode } from 'react';
type IAICheckboxProps = CheckboxProps & {
label: string;
label: string | ReactNode;
styleClass?: string;
};

View File

@ -5,13 +5,13 @@ interface IAIInputProps extends InputProps {
styleClass?: string;
label?: string;
width?: string | number;
value: string;
value?: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}
export default function IAIInput(props: IAIInputProps) {
const {
label,
label = '',
styleClass,
isDisabled = false,
fontSize = 'sm',

View File

@ -56,6 +56,7 @@ export interface OptionsState {
variationAmount: number;
width: number;
shouldUseCanvasBetaLayout: boolean;
shouldShowExistingModelsInSearch: boolean;
}
const initialOptionsState: OptionsState = {
@ -103,6 +104,7 @@ const initialOptionsState: OptionsState = {
variationAmount: 0.1,
width: 512,
shouldUseCanvasBetaLayout: false,
shouldShowExistingModelsInSearch: false,
};
const initialState: OptionsState = initialOptionsState;
@ -404,6 +406,12 @@ export const optionsSlice = createSlice({
setShouldUseCanvasBetaLayout: (state, action: PayloadAction<boolean>) => {
state.shouldUseCanvasBetaLayout = action.payload;
},
setShouldShowExistingModelsInSearch: (
state,
action: PayloadAction<boolean>
) => {
state.shouldShowExistingModelsInSearch = action.payload;
},
},
});
@ -460,6 +468,7 @@ export const {
setVariationAmount,
setWidth,
setShouldUseCanvasBetaLayout,
setShouldShowExistingModelsInSearch,
} = optionsSlice.actions;
export default optionsSlice.reducer;

View File

@ -0,0 +1,16 @@
.add-model-modal {
display: flex;
}
.add-model-modal-body {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding-bottom: 2rem;
}
.add-model-form {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
}

View File

@ -0,0 +1,369 @@
import {
Button,
Flex,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
HStack,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Text,
useDisclosure,
VStack,
} from '@chakra-ui/react';
import React from 'react';
import { FaPlus } from 'react-icons/fa';
import { Field, FieldInputProps, Formik, FormikProps } from 'formik';
import { RootState } from 'app/store';
import { addNewModel } from 'app/socketio/actions';
import { InvokeModelConfigProps } from 'app/invokeai';
import IAICheckbox from 'common/components/IAICheckbox';
import IAIButton from 'common/components/IAIButton';
import SearchModels from './SearchModels';
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import { useTranslation } from 'react-i18next';
const MIN_MODEL_SIZE = 64;
const MAX_MODEL_SIZE = 2048;
export default function AddModel() {
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isProcessing = useAppSelector(
(state: RootState) => state.system.isProcessing
);
function hasWhiteSpace(s: string) {
return /\\s/g.test(s);
}
function baseValidation(value: string) {
let error;
if (hasWhiteSpace(value)) error = t('modelmanager:cannotUseSpaces');
return error;
}
const addModelFormValues: InvokeModelConfigProps = {
name: '',
description: '',
config: 'configs/stable-diffusion/v1-inference.yaml',
weights: '',
vae: '',
width: 512,
height: 512,
default: false,
};
const addModelFormSubmitHandler = (values: InvokeModelConfigProps) => {
dispatch(addNewModel(values));
onClose();
};
const addModelModalClose = () => {
onClose();
};
const [addManually, setAddmanually] = React.useState<boolean>(false);
return (
<>
<IAIButton
aria-label={t('modelmanager:addNewModel')}
tooltip={t('modelmanager:addNewModel')}
onClick={onOpen}
className="modal-close-btn"
size={'sm'}
>
<Flex columnGap={'0.5rem'} alignItems="center">
<FaPlus />
{t('modelmanager:addNew')}
</Flex>
</IAIButton>
<Modal
isOpen={isOpen}
onClose={addModelModalClose}
size="xl"
closeOnOverlayClick={false}
>
<ModalOverlay />
<ModalContent className="modal add-model-modal">
<ModalHeader>{t('modelmanager:addNewModel')}</ModalHeader>
<ModalCloseButton />
<ModalBody className="add-model-modal-body">
<SearchModels />
<IAICheckbox
label={t('modelmanager:addManually')}
isChecked={addManually}
onChange={() => setAddmanually(!addManually)}
/>
{addManually && (
<Formik
initialValues={addModelFormValues}
onSubmit={addModelFormSubmitHandler}
>
{({ handleSubmit, errors, touched }) => (
<form onSubmit={handleSubmit}>
<VStack rowGap={'0.5rem'}>
<Text fontSize={20} fontWeight="bold" alignSelf={'start'}>
{t('modelmanager:manual')}
</Text>
{/* Name */}
<FormControl
isInvalid={!!errors.name && touched.name}
isRequired
>
<FormLabel htmlFor="name">
{t('modelmanager:name')}
</FormLabel>
<VStack alignItems={'start'}>
<Field
as={Input}
id="name"
name="name"
type="text"
validate={baseValidation}
/>
{!!errors.name && touched.name ? (
<FormErrorMessage>{errors.name}</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:nameValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
{/* Description */}
<FormControl
isInvalid={!!errors.description && touched.description}
isRequired
>
<FormLabel htmlFor="description">
{t('modelmanager:description')}
</FormLabel>
<VStack alignItems={'start'}>
<Field
as={Input}
id="description"
name="description"
type="text"
/>
{!!errors.description && touched.description ? (
<FormErrorMessage>
{errors.description}
</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:descriptionValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
{/* Config */}
<FormControl
isInvalid={!!errors.config && touched.config}
isRequired
>
<FormLabel htmlFor="config">
{t('modelmanager:config')}
</FormLabel>
<VStack alignItems={'start'}>
<Field
as={Input}
id="config"
name="config"
type="text"
/>
{!!errors.config && touched.config ? (
<FormErrorMessage>{errors.config}</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:configValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
{/* Weights */}
<FormControl
isInvalid={!!errors.weights && touched.weights}
isRequired
>
<FormLabel htmlFor="config">
{t('modelmanager:modelLocation')}
</FormLabel>
<VStack alignItems={'start'}>
<Field
as={Input}
id="weights"
name="weights"
type="text"
/>
{!!errors.weights && touched.weights ? (
<FormErrorMessage>
{errors.weights}
</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:modelLocationValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
{/* VAE */}
<FormControl isInvalid={!!errors.vae && touched.vae}>
<FormLabel htmlFor="vae">
{t('modelmanager:vaeLocation')}
</FormLabel>
<VStack alignItems={'start'}>
<Field as={Input} id="vae" name="vae" type="text" />
{!!errors.vae && touched.vae ? (
<FormErrorMessage>{errors.vae}</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:vaeLocationValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
<HStack width={'100%'}>
{/* Width */}
<FormControl
isInvalid={!!errors.width && touched.width}
>
<FormLabel htmlFor="width">
{t('modelmanager:width')}
</FormLabel>
<VStack alignItems={'start'}>
<Field id="width" name="width">
{({
field,
form,
}: {
field: FieldInputProps<number>;
form: FormikProps<InvokeModelConfigProps>;
}) => (
<NumberInput
{...field}
id="width"
name="width"
min={MIN_MODEL_SIZE}
max={MAX_MODEL_SIZE}
step={64}
onChange={(value) =>
form.setFieldValue(
field.name,
Number(value)
)
}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
)}
</Field>
{!!errors.width && touched.width ? (
<FormErrorMessage>
{errors.width}
</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:widthValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
{/* Height */}
<FormControl
isInvalid={!!errors.height && touched.height}
>
<FormLabel htmlFor="height">
{t('modelmanager:height')}
</FormLabel>
<VStack alignItems={'start'}>
<Field id="height" name="height">
{({
field,
form,
}: {
field: FieldInputProps<number>;
form: FormikProps<InvokeModelConfigProps>;
}) => (
<NumberInput
{...field}
id="height"
name="height"
min={MIN_MODEL_SIZE}
max={MAX_MODEL_SIZE}
step={64}
onChange={(value) =>
form.setFieldValue(
field.name,
Number(value)
)
}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
)}
</Field>
{!!errors.height && touched.height ? (
<FormErrorMessage>
{errors.height}
</FormErrorMessage>
) : (
<FormHelperText margin={0}>
{t('modelmanager:heightValidationMsg')}
</FormHelperText>
)}
</VStack>
</FormControl>
</HStack>
<Button
type="submit"
className="modal-close-btn"
isLoading={isProcessing}
>
{t('modelmanager:addModel')}
</Button>
</VStack>
</form>
)}
</Formik>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@ -0,0 +1,5 @@
import React from 'react';
export default function ModelEdit() {
return <div>ModelEdit</div>;
}

View File

@ -0,0 +1,97 @@
import { useState, ChangeEvent, ReactNode } from 'react';
import { Flex, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store';
import { SystemState } from 'features/system/store/systemSlice';
import AddModel from './AddModel';
import ModelListItem from './ModelListItem';
import _ from 'lodash';
import IAIInput from 'common/components/IAIInput';
import { useAppSelector } from 'app/storeHooks';
import { useTranslation } from 'react-i18next';
const modelListSelector = createSelector(
(state: RootState) => state.system,
(system: SystemState) => {
const models = _.map(system.model_list, (model, key) => {
return { name: key, ...model };
});
const activeModel = models.find((model) => model.status === 'active');
return {
models,
activeModel: activeModel,
};
}
);
const ModelList = () => {
const { models } = useAppSelector(modelListSelector);
const [searchText, setSearchText] = useState<string>('');
const { t } = useTranslation();
const handleSearchFilter = _.debounce((e: ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
}, 400);
const renderModelListItems = () => {
const modelListItemsToRender: ReactNode[] = [];
const filteredModelListItemsToRender: ReactNode[] = [];
models.forEach((model, i) => {
if (model.name.startsWith(searchText)) {
filteredModelListItemsToRender.push(
<ModelListItem
key={i}
name={model.name}
status={model.status}
description={model.description}
/>
);
}
modelListItemsToRender.push(
<ModelListItem
key={i}
name={model.name}
status={model.status}
description={model.description}
/>
);
});
return searchText !== ''
? filteredModelListItemsToRender
: modelListItemsToRender;
};
return (
<Flex flexDirection={'column'} rowGap="2rem" width={'50%'}>
<Flex justifyContent={'space-between'}>
<Text fontSize={'1.4rem'} fontWeight="bold">
{t('modelmanager:availableModels')}
</Text>
<AddModel />
</Flex>
<IAIInput
onChange={handleSearchFilter}
label={t('modelmanager:search')}
/>
<Flex
flexDirection={'column'}
gap={2}
maxHeight={window.innerHeight - 360}
overflow={'scroll'}
paddingRight="1rem"
>
{renderModelListItems()}
</Flex>
</Flex>
);
};
export default ModelList;

View File

@ -0,0 +1,95 @@
import { DeleteIcon } from '@chakra-ui/icons';
import {
Button,
Flex,
IconButton,
Spacer,
Text,
Tooltip,
} from '@chakra-ui/react';
import { ModelStatus } from 'app/invokeai';
import { deleteModel, requestModelChange } from 'app/socketio/actions';
import { RootState } from 'app/store';
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import IAIAlertDialog from 'common/components/IAIAlertDialog';
import React from 'react';
import { useTranslation } from 'react-i18next';
type ModelListItemProps = {
name: string;
status: ModelStatus;
description: string;
};
export default function ModelListItem(props: ModelListItemProps) {
const { isProcessing, isConnected } = useAppSelector(
(state: RootState) => state.system
);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { name, status, description } = props;
const handleChangeModel = () => {
dispatch(requestModelChange(name));
};
const handleModelDelete = () => {
dispatch(deleteModel(name));
};
const statusTextColor = () => {
switch (status) {
case 'active':
return 'var(--status-good-color)';
case 'cached':
return 'var(--status-working-color)';
case 'not loaded':
return 'var(--text-color-secondary)';
}
};
return (
<Flex alignItems={'center'}>
<Tooltip label={description} hasArrow placement="bottom">
<Text fontWeight={'bold'}>{name}</Text>
</Tooltip>
<Spacer />
<Flex gap={4} alignItems="center">
<Text color={statusTextColor()}>{status}</Text>
<Button
size={'sm'}
onClick={handleChangeModel}
isDisabled={status === 'active' || isProcessing || !isConnected}
className="modal-close-btn"
>
{t('modelmanager:load')}
</Button>
<IAIAlertDialog
title={t('modelmanager:deleteModel')}
acceptCallback={handleModelDelete}
acceptButtonText={t('modelmanager:delete')}
triggerComponent={
<IconButton
icon={<DeleteIcon />}
size={'sm'}
aria-label={t('modelmanager:deleteConfig')}
isDisabled={status === 'active' || isProcessing || !isConnected}
className=" modal-close-btn"
/>
}
>
<Flex rowGap={'1rem'} flexDirection="column">
<p style={{ fontWeight: 'bold' }}>{t('modelmanager:deleteMsg1')}</p>
<p style={{ color: 'var(--text-color-secondary' }}>
{t('modelmanager:deleteMsg2')}
</p>
</Flex>
</IAIAlertDialog>
</Flex>
</Flex>
);
}

View File

@ -0,0 +1,52 @@
import {
Flex,
Modal,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
useDisclosure,
} from '@chakra-ui/react';
import React, { cloneElement, ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import ModelEdit from './ModelEdit';
import ModelList from './ModelList';
type ModelManagerModalProps = {
children: ReactElement;
};
export default function ModelManagerModal({
children,
}: ModelManagerModalProps) {
const {
isOpen: isModelManagerModalOpen,
onOpen: onModelManagerModalOpen,
onClose: onModelManagerModalClose,
} = useDisclosure();
const { t } = useTranslation();
return (
<>
{cloneElement(children, {
onClick: onModelManagerModalOpen,
})}
<Modal
isOpen={isModelManagerModalOpen}
onClose={onModelManagerModalClose}
size="6xl"
>
<ModalOverlay />
<ModalContent className=" modal">
<ModalCloseButton className="modal-close-btn" />
<ModalHeader>{t('modelmanager:modelManager')}</ModalHeader>
<Flex padding={'0 2rem 2rem 2rem'} width="100%" columnGap={'2rem'}>
<ModelList />
<ModelEdit />
</Flex>
</ModalContent>
</Modal>
</>
);
}

View File

@ -0,0 +1,315 @@
import { Box, Flex, VStack } from '@chakra-ui/react';
import { addNewModel, searchForModels } from 'app/socketio/actions';
import { RootState } from 'app/store';
import IAICheckbox from 'common/components/IAICheckbox';
import React, { ReactNode, ChangeEvent } from 'react';
import { MdFindInPage } from 'react-icons/md';
import _ from 'lodash';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaPlus } from 'react-icons/fa';
import {
setFoundModels,
setSearchFolder,
} from 'features/system/store/systemSlice';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
import { setShouldShowExistingModelsInSearch } from 'features/options/store/optionsSlice';
import { FoundModel } from 'app/invokeai';
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import { useTranslation } from 'react-i18next';
const existingModelsSelector = createSelector([systemSelector], (system) => {
const { model_list } = system;
const existingModels: string[] = [];
_.forEach(model_list, (value) => {
existingModels.push(value.weights);
});
return existingModels;
});
function ModelExistsTag() {
const { t } = useTranslation();
return (
<Box
position={'absolute'}
zIndex={2}
right={4}
top={4}
fontSize="0.7rem"
fontWeight={'bold'}
backgroundColor={'var(--accent-color)'}
padding={'0.2rem 0.5rem'}
borderRadius="0.2rem"
alignItems={'center'}
>
{t('modelmanager:modelExists')}
</Box>
);
}
interface SearchModelEntry {
model: FoundModel;
modelsToAdd: string[];
setModelsToAdd: React.Dispatch<React.SetStateAction<string[]>>;
}
function SearchModelEntry({
model,
modelsToAdd,
setModelsToAdd,
}: SearchModelEntry) {
const existingModels = useAppSelector(existingModelsSelector);
const foundModelsChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
if (!modelsToAdd.includes(e.target.value)) {
setModelsToAdd([...modelsToAdd, e.target.value]);
} else {
setModelsToAdd(_.remove(modelsToAdd, (v) => v !== e.target.value));
}
};
return (
<Box position="relative">
{existingModels.includes(model.location) ? <ModelExistsTag /> : null}
<IAICheckbox
value={model.name}
label={
<>
<VStack alignItems={'start'}>
<p style={{ fontWeight: 'bold' }}>{model.name}</p>
<p style={{ fontStyle: 'italic' }}>{model.location}</p>
</VStack>
</>
}
isChecked={modelsToAdd.includes(model.name)}
isDisabled={existingModels.includes(model.location)}
onChange={foundModelsChangeHandler}
padding={'1rem'}
backgroundColor={'var(--background-color)'}
borderRadius={'0.5rem'}
_checked={{
backgroundColor: 'var(--accent-color)',
color: 'var(--text-color)',
}}
_disabled={{
backgroundColor: 'var(--background-color-secondary)',
}}
></IAICheckbox>
</Box>
);
}
export default function SearchModels() {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const searchFolder = useAppSelector(
(state: RootState) => state.system.searchFolder
);
const foundModels = useAppSelector(
(state: RootState) => state.system.foundModels
);
const existingModels = useAppSelector(existingModelsSelector);
const shouldShowExistingModelsInSearch = useAppSelector(
(state: RootState) => state.options.shouldShowExistingModelsInSearch
);
const [modelsToAdd, setModelsToAdd] = React.useState<string[]>([]);
const resetSearchModelHandler = () => {
dispatch(setSearchFolder(null));
dispatch(setFoundModels(null));
setModelsToAdd([]);
};
const findModelsHandler = () => {
dispatch(searchForModels());
};
const addAllToSelected = () => {
setModelsToAdd([]);
if (foundModels) {
foundModels.forEach((model) => {
if (!existingModels.includes(model.location)) {
setModelsToAdd((currentModels) => {
return [...currentModels, model.name];
});
}
});
}
};
const removeAllFromSelected = () => {
setModelsToAdd([]);
};
const addSelectedModels = () => {
const modelsToBeAdded = foundModels?.filter((foundModel) =>
modelsToAdd.includes(foundModel.name)
);
modelsToBeAdded?.forEach((model) => {
const modelFormat = {
name: model.name,
description: '',
config: 'configs/stable-diffusion/v1-inference.yaml',
weights: model.location,
vae: '',
width: 512,
height: 512,
default: false,
};
dispatch(addNewModel(modelFormat));
});
setModelsToAdd([]);
};
const renderFoundModels = () => {
const newFoundModels: ReactNode[] = [];
const existingFoundModels: ReactNode[] = [];
if (foundModels) {
foundModels.forEach((model, index) => {
if (existingModels.includes(model.location)) {
existingFoundModels.push(
<SearchModelEntry
key={index}
model={model}
modelsToAdd={modelsToAdd}
setModelsToAdd={setModelsToAdd}
/>
);
} else {
newFoundModels.push(
<SearchModelEntry
key={index}
model={model}
modelsToAdd={modelsToAdd}
setModelsToAdd={setModelsToAdd}
/>
);
}
});
}
return (
<>
{newFoundModels}
{shouldShowExistingModelsInSearch && existingFoundModels}
</>
);
};
return (
<>
{searchFolder ? (
<Flex
flexDirection={'column'}
padding={'1rem'}
backgroundColor={'var(--background-color)'}
borderRadius="0.5rem"
rowGap={'0.5rem'}
position={'relative'}
>
<p
style={{
fontWeight: 'bold',
fontSize: '0.8rem',
backgroundColor: 'var(--background-color-secondary)',
padding: '0.2rem 1rem',
width: 'max-content',
borderRadius: '0.2rem',
}}
>
{t('modelmanager:checkpointFolder')}
</p>
<p
style={{ fontWeight: 'bold', fontSize: '0.8rem', maxWidth: '80%' }}
>
{searchFolder}
</p>
<IAIIconButton
aria-label={t('modelmanager:clearCheckpointFolder')}
icon={<FaPlus style={{ transform: 'rotate(45deg)' }} />}
position={'absolute'}
right={5}
onClick={resetSearchModelHandler}
/>
</Flex>
) : (
<IAIButton
aria-label={t('modelmanager:findModels')}
onClick={findModelsHandler}
>
<Flex columnGap={'0.5rem'}>
<MdFindInPage fontSize={20} />
{t('modelmanager:selectFolder')}
</Flex>
</IAIButton>
)}
{foundModels && (
<Flex flexDirection={'column'} rowGap={'1rem'}>
<Flex justifyContent={'space-between'} alignItems="center">
<p>
{t('modelmanager:modelsFound')}: {foundModels.length}
</p>
<p>
{t('modelmanager:selected')}: {modelsToAdd.length}
</p>
</Flex>
<Flex columnGap={'0.5rem'} justifyContent={'space-between'}>
<Flex columnGap={'0.5rem'}>
<IAIButton
isDisabled={modelsToAdd.length === foundModels.length}
onClick={addAllToSelected}
>
{t('modelmanager:selectAll')}
</IAIButton>
<IAIButton
isDisabled={modelsToAdd.length === 0}
onClick={removeAllFromSelected}
>
{t('modelmanager:deselectAll')}
</IAIButton>
<IAICheckbox
label={t('modelmanager:showExisting')}
isChecked={shouldShowExistingModelsInSearch}
onChange={() =>
dispatch(
setShouldShowExistingModelsInSearch(
!shouldShowExistingModelsInSearch
)
)
}
/>
</Flex>
<IAIButton
isDisabled={modelsToAdd.length === 0}
onClick={addSelectedModels}
>
{t('modelmanager:addSelected')}
</IAIButton>
</Flex>
<Flex
rowGap={'1rem'}
flexDirection="column"
maxHeight={'18rem'}
overflowY="scroll"
paddingRight={'1rem'}
paddingLeft={'0.2rem'}
>
{renderFoundModels()}
</Flex>
</Flex>
)}
</>
);
}

View File

@ -1,93 +0,0 @@
// .chakra-accordion {
// display: grid;
// row-gap: 0.5rem;
// }
// .chakra-accordion__item {
// border: none;
// }
// button {
// border-radius: 0.3rem;
// &[aria-expanded='true'] {
// // background-color: var(--tab-hover-color);
// border-radius: 0.3rem;
// }
// }
.model-list-accordion {
outline: none;
padding: 0.25rem;
button {
padding: 0;
margin: 0;
&:hover {
background-color: unset;
}
}
div {
border: none;
}
.model-list-button {
display: flex;
flex-direction: row;
row-gap: 0.5rem;
justify-content: space-between;
align-items: center;
width: 100%;
}
.model-list-header-hint {
color: var(--text-color-secondary);
font-weight: normal;
}
.model-list-list {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
.model-list-item {
display: flex;
column-gap: 0.5rem;
width: 100%;
justify-content: space-between;
align-items: center;
.model-list-item-name {
}
.model-list-item-description {
font-size: 0.9rem;
}
.model-list-item-status {
&.active {
color: var(--status-good-color);
}
&.cached {
color: var(--status-working-color);
}
&.not-loaded {
color: var(--text-color-secondary);
}
}
.model-list-item-load-btn {
button {
padding: 0.5rem;
background-color: var(--btn-base-color);
color: var(--text-color);
border-radius: 0.2rem;
&:hover {
background-color: var(--btn-base-color-hover);
}
}
}
}
}
}

View File

@ -1,117 +0,0 @@
import {
Button,
Tooltip,
Spacer,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Text,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { ModelStatus } from 'app/invokeai';
import { requestModelChange } from 'app/socketio/actions';
import { RootState } from 'app/store';
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import { SystemState } from 'features/system/store/systemSlice';
import { useTranslation } from 'react-i18next';
type ModelListItemProps = {
name: string;
status: ModelStatus;
description: string;
};
const ModelListItem = (props: ModelListItemProps) => {
const { isProcessing, isConnected } = useAppSelector(
(state: RootState) => state.system
);
const dispatch = useAppDispatch();
const { name, status, description } = props;
const handleChangeModel = () => {
dispatch(requestModelChange(name));
};
const { t } = useTranslation();
return (
<div className="model-list-item">
<Tooltip label={description} hasArrow placement="bottom">
<div className="model-list-item-name">{name}</div>
</Tooltip>
<Spacer />
<div className={`model-list-item-status ${status.split(' ').join('-')}`}>
{status}
</div>
<div className="model-list-item-load-btn">
<Button
size={'sm'}
onClick={handleChangeModel}
isDisabled={status === 'active' || isProcessing || !isConnected}
>
{t('common:load')}
</Button>
</div>
</div>
);
};
const modelListSelector = createSelector(
(state: RootState) => state.system,
(system: SystemState) => {
const models = _.map(system.model_list, (model, key) => {
return { name: key, ...model };
});
const activeModel = models.find((model) => model.status === 'active');
return {
models,
activeModel: activeModel,
};
}
);
const ModelList = () => {
const { models } = useAppSelector(modelListSelector);
const { t } = useTranslation();
return (
<Accordion
allowToggle
className="model-list-accordion"
variant={'unstyled'}
>
<AccordionItem>
<AccordionButton>
<div className="model-list-button">
<Text
fontSize="sm"
fontWeight="bold"
color="var(--text-color-secondary)"
>
{t('settings:models')}
</Text>
<AccordionIcon />
</div>
</AccordionButton>
<AccordionPanel>
<div className="model-list-list">
{models.map((model, i) => (
<ModelListItem
key={i}
name={model.name}
status={model.status}
description={model.description}
/>
))}
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default ModelList;

View File

@ -26,7 +26,6 @@ import {
setShouldDisplayGuides,
setShouldDisplayInProgressType,
} from 'features/system/store/systemSlice';
import ModelList from './ModelList';
import { IN_PROGRESS_IMAGE_TYPES } from 'app/constants';
import IAISwitch from 'common/components/IAISwitch';
import IAISelect from 'common/components/IAISelect';
@ -139,9 +138,6 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
<ModalCloseButton className="modal-close-btn" />
<ModalBody className="settings-modal-content">
<div className="settings-modal-items">
<div className="settings-modal-item">
<ModelList />
</div>
<div
className="settings-modal-item"
style={{ gridAutoFlow: 'row', rowGap: '0.5rem' }}

View File

@ -6,6 +6,7 @@ import {
FaBug,
FaKeyboard,
FaWrench,
FaCube,
} from 'react-icons/fa';
import InvokeAILogo from 'assets/images/logo.png';
@ -17,6 +18,8 @@ import SettingsModal from './SettingsModal/SettingsModal';
import StatusIndicator from './StatusIndicator';
import ThemeChanger from './ThemeChanger';
import ModelSelect from './ModelSelect';
import ModelManagerModal from './ModelManager/ModelManagerModal';
import LanguagePicker from './LanguagePicker';
import { useTranslation } from 'react-i18next';
@ -41,6 +44,18 @@ const SiteHeader = () => {
<ModelSelect />
<ModelManagerModal>
<IAIIconButton
aria-label={t('modelmanager:modelManager')}
tooltip={t('modelmanager:modelManager')}
size={'sm'}
variant="link"
data-variant="link"
fontSize={20}
icon={<FaCube />}
/>
</ModelManagerModal>
<HotkeysModal>
<IAIIconButton
aria-label={t('common:hotkeysLabel')}

View File

@ -47,6 +47,8 @@ export interface SystemState
saveIntermediatesInterval: number;
enableImageDebugging: boolean;
toastQueue: UseToastOptions[];
searchFolder: string | null;
foundModels: InvokeAI.FoundModel[] | null;
}
const initialSystemState: SystemState = {
@ -82,6 +84,8 @@ const initialSystemState: SystemState = {
saveIntermediatesInterval: 5,
enableImageDebugging: false,
toastQueue: [],
searchFolder: null,
foundModels: null,
};
export const systemSlice = createSlice({
@ -225,6 +229,15 @@ export const systemSlice = createSlice({
state.currentStatus = action.payload;
state.currentStatusHasSteps = false;
},
setSearchFolder: (state, action: PayloadAction<string | null>) => {
state.searchFolder = action.payload;
},
setFoundModels: (
state,
action: PayloadAction<InvokeAI.FoundModel[] | null>
) => {
state.foundModels = action.payload;
},
},
});
@ -253,6 +266,8 @@ export const {
addToast,
clearToastQueue,
setProcessingIndeterminateTask,
setSearchFolder,
setFoundModels,
} = systemSlice.actions;
export default systemSlice.reducer;

View File

@ -8,6 +8,7 @@ import gallery from '../public/locales/gallery/en.json';
import toast from '../public/locales/toast/en.json';
import hotkeys from '../public/locales/hotkeys/en.json';
import settings from '../public/locales/settings/en.json';
import modelmanager from '../public/locales/modelmanager/en.json';
declare module 'i18next' {
// Extend CustomTypeOptions
@ -23,6 +24,7 @@ declare module 'i18next' {
toast: typeof toast;
hotkeys: typeof hotkeys;
settings: typeof settings;
modelmanager: typeof modelmanager;
};
// Never Return Null
returnNull: false;

View File

@ -18,6 +18,7 @@ i18n
'toast',
'hotkeys',
'settings',
'modelmanager',
],
backend: {
loadPath: '/locales/{{ns}}/{{lng}}.json',

View File

@ -16,7 +16,7 @@
@use '../features/system/components/SiteHeader.scss';
@use '../features/system/components/StatusIndicator.scss';
@use '../features/system/components/SettingsModal/SettingsModal.scss';
@use '../features/system/components/SettingsModal/ModelList.scss';
@use '../features/system/components/ModelManager/AddModel.scss';
@use '../features/system/components/HotkeysModal/HotkeysModal.scss';
@use '../features/system/components/Console.scss';

View File

@ -2331,6 +2331,11 @@ deep-is@^0.1.3, deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170"
integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==
defaults@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a"
@ -2946,6 +2951,19 @@ focus-lock@^0.11.2:
dependencies:
tslib "^2.0.3"
formik@^2.2.9:
version "2.2.9"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0"
integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==
dependencies:
deepmerge "^2.1.1"
hoist-non-react-statics "^3.3.0"
lodash "^4.17.21"
lodash-es "^4.17.21"
react-fast-compare "^2.0.1"
tiny-warning "^1.0.2"
tslib "^1.10.0"
framer-motion@^7.2.1:
version "7.6.9"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-7.6.9.tgz#d2c8ca8b97580aa00f7c6e2b616da241bd2ce8e6"
@ -3445,6 +3463,11 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@ -4021,6 +4044,11 @@ react-fast-compare@3.2.0:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-fast-compare@^2.0.1:
version "2.0.4"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
react-focus-lock@^2.9.1:
version "2.9.2"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.9.2.tgz#a57dfd7c493e5a030d87f161c96ffd082bd920f2"
@ -4602,6 +4630,11 @@ tiny-invariant@^1.0.6:
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
tiny-warning@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@ -4671,7 +4704,7 @@ tslib@2.4.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tslib@^1.8.1, tslib@^1.9.3:
tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==