feat(ui): style add model panel

This commit is contained in:
psychedelicious 2024-03-13 20:50:23 +11:00
parent d93d4afbb7
commit 8ef8082d65
11 changed files with 126 additions and 109 deletions

View File

@ -768,8 +768,7 @@
"huggingFaceHelper": "If multiple models are found in this repo, you will be prompted to select one to install.", "huggingFaceHelper": "If multiple models are found in this repo, you will be prompted to select one to install.",
"ignoreMismatch": "Ignore Mismatches Between Selected Models", "ignoreMismatch": "Ignore Mismatches Between Selected Models",
"imageEncoderModelId": "Image Encoder Model ID", "imageEncoderModelId": "Image Encoder Model ID",
"importModels": "Import Models", "installQueue": "Install Queue",
"importQueue": "Import Queue",
"inpainting": "v1 Inpainting", "inpainting": "v1 Inpainting",
"inplaceInstall": "In-place install", "inplaceInstall": "In-place install",
"inplaceInstallDesc": "Install models without copying the files. When using the model, it will be loaded from its this location. If disabled, the model file(s) will be copied into the Invoke-managed models directory during installation.", "inplaceInstallDesc": "Install models without copying the files. When using the model, it will be loaded from its this location. If disabled, the model file(s) will be copied into the Invoke-managed models directory during installation.",
@ -803,7 +802,6 @@
"modelImageUpdated": "Model Image Updated", "modelImageUpdated": "Model Image Updated",
"modelImageUpdateFailed": "Model Image Update Failed", "modelImageUpdateFailed": "Model Image Update Failed",
"modelLocation": "Model Location", "modelLocation": "Model Location",
"modelLocationValidationMsg": "Provide the path to a local folder where your Diffusers Model is stored",
"modelManager": "Model Manager", "modelManager": "Model Manager",
"modelMergeAlphaHelp": "Alpha controls blend strength for the models. Lower alpha values lead to lower influence of the second model.", "modelMergeAlphaHelp": "Alpha controls blend strength for the models. Lower alpha values lead to lower influence of the second model.",
"modelMergeHeaderHelp1": "You can merge up to three different models to create a blend that suits your needs.", "modelMergeHeaderHelp1": "You can merge up to three different models to create a blend that suits your needs.",
@ -848,7 +846,8 @@
"safetensorModels": "SafeTensors", "safetensorModels": "SafeTensors",
"sameFolder": "Same folder", "sameFolder": "Same folder",
"scan": "Scan", "scan": "Scan",
"scanFolder": "Scan folder", "scanFolder": "Scan Folder",
"scanFolderHelper": "The folder will be recursively scanned for models. This can take a few moments for very large folders.",
"scanAgain": "Scan Again", "scanAgain": "Scan Again",
"scanForModels": "Scan For Models", "scanForModels": "Scan For Models",
"scanPlaceholder": "Path to a local folder", "scanPlaceholder": "Path to a local folder",
@ -875,6 +874,8 @@
"upcastAttention": "Upcast Attention", "upcastAttention": "Upcast Attention",
"uploadImage": "Upload Image", "uploadImage": "Upload Image",
"updateModel": "Update Model", "updateModel": "Update Model",
"urlOrLocalPath": "URL or Local Path",
"urlOrLocalPathHelper": "URLs should point to a single file. Local paths can point to a single file or folder for a single diffusers model.",
"useCustomConfig": "Use Custom Config", "useCustomConfig": "Use Custom Config",
"useDefaultSettings": "Use Default Settings", "useDefaultSettings": "Use Default Settings",
"v1": "v1", "v1": "v1",

View File

@ -44,7 +44,7 @@ export const HuggingFaceResultItem = ({ result }: Props) => {
}, [installModel, result, dispatch, t]); }, [installModel, result, dispatch, t]);
return ( return (
<Flex alignItems="center" justifyContent="space-between" w="100%" gap={4}> <Flex alignItems="center" justifyContent="space-between" w="100%" gap={3}>
<Flex fontSize="sm" flexDir="column"> <Flex fontSize="sm" flexDir="column">
<Text fontWeight="semibold">{result.split('/').slice(-1)[0]}</Text> <Text fontWeight="semibold">{result.split('/').slice(-1)[0]}</Text>
<Text variant="subtext" noOfLines={1} wordBreak="break-all"> <Text variant="subtext" noOfLines={1} wordBreak="break-all">

View File

@ -87,7 +87,7 @@ export const HuggingFaceResults = ({ results }: HuggingFaceResultsProps) => {
<Button size="sm" onClick={handleAddAll} isDisabled={results.length === 0} flexShrink={0}> <Button size="sm" onClick={handleAddAll} isDisabled={results.length === 0} flexShrink={0}>
{t('modelManager.installAll')} {t('modelManager.installAll')}
</Button> </Button>
<InputGroup maxW="300px" size="xs"> <InputGroup w={64} size="xs">
<Input <Input
placeholder={t('modelManager.search')} placeholder={t('modelManager.search')}
value={searchTerm} value={searchTerm}
@ -110,7 +110,7 @@ export const HuggingFaceResults = ({ results }: HuggingFaceResultsProps) => {
</InputGroup> </InputGroup>
</Flex> </Flex>
</Flex> </Flex>
<Flex height="100%" layerStyle="third" borderRadius="base" p={4}> <Flex height="100%" layerStyle="third" borderRadius="base" p={3}>
<ScrollableContent> <ScrollableContent>
<Flex flexDir="column" gap={3}> <Flex flexDir="column" gap={3}>
{filteredResults.map((result) => ( {filteredResults.map((result) => (

View File

@ -67,17 +67,21 @@ export const InstallModelForm = () => {
<Flex flexDir="column" gap={4}> <Flex flexDir="column" gap={4}>
<Flex gap={2} alignItems="flex-end" justifyContent="space-between"> <Flex gap={2} alignItems="flex-end" justifyContent="space-between">
<FormControl orientation="vertical"> <FormControl orientation="vertical">
<FormLabel>{t('modelManager.modelLocation')}</FormLabel> <FormLabel>{t('modelManager.urlOrLocalPath')}</FormLabel>
<Input placeholder={t('modelManager.simpleModelPlaceholder')} {...register('location')} /> <Flex alignItems="center" gap={3} w="full">
<Input placeholder={t('modelManager.simpleModelPlaceholder')} {...register('location')} />
<Button
onClick={handleSubmit(onSubmit)}
isDisabled={!formState.dirtyFields.location}
isLoading={isLoading}
type="submit"
size="sm"
>
{t('modelManager.install')}
</Button>
</Flex>
<FormHelperText>{t('modelManager.urlOrLocalPathHelper')}</FormHelperText>
</FormControl> </FormControl>
<Button
onClick={handleSubmit(onSubmit)}
isDisabled={!formState.dirtyFields.location}
isLoading={isLoading}
type="submit"
>
{t('modelManager.addModel')}
</Button>
</Flex> </Flex>
<FormControl> <FormControl>

View File

@ -1,4 +1,4 @@
import { Box, Button, Flex, Text } from '@invoke-ai/ui-library'; import { Box, Button, Flex, Heading } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
@ -50,9 +50,9 @@ export const ModelInstallQueue = () => {
}, [data]); }, [data]);
return ( return (
<Flex flexDir="column" p={3} h="full"> <Flex flexDir="column" p={3} h="full" gap={3}>
<Flex justifyContent="space-between" alignItems="center"> <Flex justifyContent="space-between" alignItems="center">
<Text>{t('modelManager.importQueue')}</Text> <Heading size="sm">{t('modelManager.installQueue')}</Heading>
<Button <Button
size="sm" size="sm"
isDisabled={!pruneAvailable} isDisabled={!pruneAvailable}
@ -62,9 +62,9 @@ export const ModelInstallQueue = () => {
{t('modelManager.prune')} {t('modelManager.prune')}
</Button> </Button>
</Flex> </Flex>
<Box mt={3} layerStyle="first" p={3} borderRadius="base" w="full" h="full"> <Box layerStyle="first" p={3} borderRadius="base" w="full" h="full">
<ScrollableContent> <ScrollableContent>
<Flex flexDir="column-reverse" gap="2"> <Flex flexDir="column-reverse" gap="2" w="full">
{data?.map((model) => <ModelInstallQueueItem key={model.id} installJob={model} />)} {data?.map((model) => <ModelInstallQueueItem key={model.id} installJob={model} />)}
</Flex> </Flex>
</ScrollableContent> </ScrollableContent>

View File

@ -1,4 +1,4 @@
import { Badge, Tooltip } from '@invoke-ai/ui-library'; import { Badge } from '@invoke-ai/ui-library';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { ModelInstallStatus } from 'services/api/types'; import type { ModelInstallStatus } from 'services/api/types';
@ -13,13 +13,7 @@ const STATUSES = {
cancelled: { colorScheme: 'orange', translationKey: 'queue.canceled' }, cancelled: { colorScheme: 'orange', translationKey: 'queue.canceled' },
}; };
const ModelInstallQueueBadge = ({ const ModelInstallQueueBadge = ({ status }: { status?: ModelInstallStatus }) => {
status,
errorReason,
}: {
status?: ModelInstallStatus;
errorReason?: string | null;
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!status || !Object.keys(STATUSES).includes(status)) { if (!status || !Object.keys(STATUSES).includes(status)) {
@ -27,9 +21,9 @@ const ModelInstallQueueBadge = ({
} }
return ( return (
<Tooltip label={errorReason}> <Badge textAlign="center" w="134px" colorScheme={STATUSES[status].colorScheme}>
<Badge colorScheme={STATUSES[status].colorScheme}>{t(STATUSES[status].translationKey)}</Badge> {t(STATUSES[status].translationKey)}
</Tooltip> </Badge>
); );
}; };
export default memo(ModelInstallQueueBadge); export default memo(ModelInstallQueueBadge);

View File

@ -1,4 +1,4 @@
import { Box, Flex, IconButton, Progress, Text, Tooltip } from '@invoke-ai/ui-library'; import { Flex, IconButton, Progress, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast'; import { makeToast } from 'features/system/util/makeToast';
@ -7,7 +7,7 @@ import { isNil } from 'lodash-es';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { PiXBold } from 'react-icons/pi'; import { PiXBold } from 'react-icons/pi';
import { useCancelModelInstallMutation } from 'services/api/endpoints/models'; import { useCancelModelInstallMutation } from 'services/api/endpoints/models';
import type { HFModelSource, LocalModelSource, ModelInstallJob, URLModelSource } from 'services/api/types'; import type { ModelInstallJob } from 'services/api/types';
import ModelInstallQueueBadge from './ModelInstallQueueBadge'; import ModelInstallQueueBadge from './ModelInstallQueueBadge';
@ -16,7 +16,7 @@ type ModelListItemProps = {
}; };
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
const units = ['b', 'kb', 'mb', 'gb', 'tb']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0; let i = 0;
@ -33,18 +33,6 @@ export const ModelInstallQueueItem = (props: ModelListItemProps) => {
const [deleteImportModel] = useCancelModelInstallMutation(); const [deleteImportModel] = useCancelModelInstallMutation();
const source = useMemo(() => {
if (installJob.source.type === 'hf') {
return installJob.source as HFModelSource;
} else if (installJob.source.type === 'local') {
return installJob.source as LocalModelSource;
} else if (installJob.source.type === 'url') {
return installJob.source as URLModelSource;
} else {
return installJob.source as LocalModelSource;
}
}, [installJob.source]);
const handleDeleteModelImport = useCallback(() => { const handleDeleteModelImport = useCallback(() => {
deleteImportModel(installJob.id) deleteImportModel(installJob.id)
.unwrap() .unwrap()
@ -72,18 +60,31 @@ export const ModelInstallQueueItem = (props: ModelListItemProps) => {
}); });
}, [deleteImportModel, installJob, dispatch]); }, [deleteImportModel, installJob, dispatch]);
const modelName = useMemo(() => { const sourceLocation = useMemo(() => {
switch (source.type) { switch (installJob.source.type) {
case 'hf': case 'hf':
return source.repo_id; return installJob.source.repo_id;
case 'url': case 'url':
return source.url; return installJob.source.url;
case 'local': case 'local':
return source.path.split('\\').slice(-1)[0]; return installJob.source.path;
default: default:
return ''; return t('common.unknown');
} }
}, [source]); }, [installJob.source]);
const modelName = useMemo(() => {
switch (installJob.source.type) {
case 'hf':
return installJob.source.repo_id;
case 'url':
return installJob.source.url.split('/').slice(-1)[0] ?? t('common.unknown');
case 'local':
return installJob.source.path.split('\\').slice(-1)[0] ?? t('common.unknown');
default:
return t('common.unknown');
}
}, [installJob.source]);
const progressValue = useMemo(() => { const progressValue = useMemo(() => {
if (isNil(installJob.bytes) || isNil(installJob.total_bytes)) { if (isNil(installJob.bytes) || isNil(installJob.total_bytes)) {
@ -97,48 +98,67 @@ export const ModelInstallQueueItem = (props: ModelListItemProps) => {
return (installJob.bytes / installJob.total_bytes) * 100; return (installJob.bytes / installJob.total_bytes) * 100;
}, [installJob.bytes, installJob.total_bytes]); }, [installJob.bytes, installJob.total_bytes]);
return (
<Flex gap={3} w="full" alignItems="center">
<Tooltip maxW={600} label={<TooltipLabel name={modelName} source={sourceLocation} installJob={installJob} />}>
<Flex gap={3} w="full" alignItems="center">
<Text w={96} whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
{modelName}
</Text>
<Progress
w="full"
flexGrow={1}
value={progressValue ?? 0}
isIndeterminate={progressValue === null}
aria-label={t('accessibility.invokeProgressBar')}
h={2}
/>
<ModelInstallQueueBadge status={installJob.status} />
</Flex>
</Tooltip>
<IconButton
isDisabled={
installJob.status !== 'downloading' && installJob.status !== 'waiting' && installJob.status !== 'running'
}
size="xs"
tooltip={t('modelManager.cancel')}
aria-label={t('modelManager.cancel')}
icon={<PiXBold />}
onClick={handleDeleteModelImport}
variant="ghost"
/>
</Flex>
);
};
type TooltipLabelProps = {
installJob: ModelInstallJob;
name: string;
source: string;
};
const TooltipLabel = ({ name, source, installJob }: TooltipLabelProps) => {
const progressString = useMemo(() => { const progressString = useMemo(() => {
if (installJob.status !== 'downloading' || installJob.bytes === undefined || installJob.total_bytes === undefined) { if (installJob.status === 'downloading' || installJob.bytes === undefined || installJob.total_bytes === undefined) {
return ''; return '';
} }
return `${formatBytes(installJob.bytes)} / ${formatBytes(installJob.total_bytes)}`; return `${formatBytes(installJob.bytes)} / ${formatBytes(installJob.total_bytes)}`;
}, [installJob.bytes, installJob.total_bytes, installJob.status]); }, [installJob.bytes, installJob.total_bytes, installJob.status]);
return ( return (
<Flex gap="2" w="full" alignItems="center"> <>
<Tooltip label={modelName}> <Flex gap={3} justifyContent="space-between">
<Text width="30%" whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis"> <Text fontWeight="semibold">{name}</Text>
{modelName} {progressString && <Text>{progressString}</Text>}
</Text>
</Tooltip>
<Flex flexDir="column" flex={1}>
<Tooltip label={progressString}>
<Progress
value={progressValue ?? 0}
isIndeterminate={progressValue === null}
aria-label={t('accessibility.invokeProgressBar')}
h={2}
/>
</Tooltip>
</Flex> </Flex>
<Box minW="100px" textAlign="center"> <Text fontStyle="italic" wordBreak="break-all">
<ModelInstallQueueBadge status={installJob.status} errorReason={installJob.error_reason} /> {source}
</Box> </Text>
{installJob.error_reason && (
<Box minW="20px"> <Text color="error.500">
{(installJob.status === 'downloading' || {t('queue.failed')}: {installJob.error}
installJob.status === 'waiting' || </Text>
installJob.status === 'running') && ( )}
<IconButton </>
isRound={true}
size="xs"
tooltip={t('modelManager.cancel')}
aria-label={t('modelManager.cancel')}
icon={<PiXBold />}
onClick={handleDeleteModelImport}
/>
)}
</Box>
</Flex>
); );
}; };

View File

@ -1,4 +1,4 @@
import { Button, Flex, FormControl, FormErrorMessage, FormLabel, Input } from '@invoke-ai/ui-library'; import { Button, Flex, FormControl, FormErrorMessage, FormHelperText, FormLabel, Input } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { setScanPath } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { setScanPath } from 'features/modelManagerV2/store/modelManagerV2Slice';
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
@ -35,8 +35,8 @@ export const ScanModelsForm = () => {
); );
return ( return (
<Flex flexDir="column" height="100%"> <Flex flexDir="column" height="100%" gap={3}>
<FormControl isInvalid={!!errorMessage.length} w="full" orientation="vertical"> <FormControl isInvalid={!!errorMessage.length} w="full" orientation="vertical" flexShrink={0}>
<FormLabel>{t('common.folder')}</FormLabel> <FormLabel>{t('common.folder')}</FormLabel>
<Flex gap={3} alignItems="center" w="full"> <Flex gap={3} alignItems="center" w="full">
<Input placeholder={t('modelManager.scanPlaceholder')} value={scanPath} onChange={handleSetScanPath} /> <Input placeholder={t('modelManager.scanPlaceholder')} value={scanPath} onChange={handleSetScanPath} />
@ -50,6 +50,7 @@ export const ScanModelsForm = () => {
{t('modelManager.scanFolder')} {t('modelManager.scanFolder')}
</Button> </Button>
</Flex> </Flex>
<FormHelperText>{t('modelManager.scanFolderHelper')}</FormHelperText>
{!!errorMessage.length && <FormErrorMessage>{errorMessage}</FormErrorMessage>} {!!errorMessage.length && <FormErrorMessage>{errorMessage}</FormErrorMessage>}
</FormControl> </FormControl>
{data && <ScanModelsResults results={data} />} {data && <ScanModelsResults results={data} />}

View File

@ -1,4 +1,4 @@
import { Badge, Box, Flex, IconButton, Text, Tooltip } from '@invoke-ai/ui-library'; import { Badge, Box, Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast'; import { makeToast } from 'features/system/util/makeToast';
@ -45,7 +45,7 @@ export const ScanModelResultItem = ({ result }: Props) => {
}, [installModel, result, dispatch, t]); }, [installModel, result, dispatch, t]);
return ( return (
<Flex justifyContent="space-between"> <Flex alignItems="center" justifyContent="space-between" w="100%" gap={3}>
<Flex fontSize="sm" flexDir="column"> <Flex fontSize="sm" flexDir="column">
<Text fontWeight="semibold">{result.path.split('\\').slice(-1)[0]}</Text> <Text fontWeight="semibold">{result.path.split('\\').slice(-1)[0]}</Text>
<Text variant="subtext">{result.path}</Text> <Text variant="subtext">{result.path}</Text>
@ -54,9 +54,7 @@ export const ScanModelResultItem = ({ result }: Props) => {
{result.is_installed ? ( {result.is_installed ? (
<Badge>{t('common.installed')}</Badge> <Badge>{t('common.installed')}</Badge>
) : ( ) : (
<Tooltip label={t('modelManager.quickAdd')}> <IconButton aria-label={t('modelManager.install')} icon={<PiPlusBold />} onClick={handleQuickAdd} size="sm" />
<IconButton aria-label={t('modelManager.quickAdd')} icon={<PiPlusBold />} onClick={handleQuickAdd} />
</Tooltip>
)} )}
</Box> </Box>
</Flex> </Flex>

View File

@ -80,17 +80,15 @@ export const ScanModelsResults = ({ results }: ScanModelResultsProps) => {
return ( return (
<> <>
<Divider mt={6} /> <Divider />
<Flex flexDir="column" gap={2} mt={4} height="100%"> <Flex flexDir="column" gap={3} height="100%">
<Flex justifyContent="space-between" alignItems="center"> <Flex justifyContent="space-between" alignItems="center">
<Heading fontSize="md" as="h4"> <Heading size="sm">{t('modelManager.scanResults')}</Heading>
{t('modelManager.scanResults')} <Flex alignItems="center" gap={3}>
</Heading>
<Flex alignItems="center" gap="4">
<Button size="sm" onClick={handleAddAll} isDisabled={filteredResults.length === 0}> <Button size="sm" onClick={handleAddAll} isDisabled={filteredResults.length === 0}>
{t('modelManager.addAll')} {t('modelManager.installAll')}
</Button> </Button>
<InputGroup maxW="300px" size="xs"> <InputGroup w={64} size="xs">
<Input <Input
placeholder={t('modelManager.search')} placeholder={t('modelManager.search')}
value={searchTerm} value={searchTerm}
@ -107,13 +105,14 @@ export const ScanModelsResults = ({ results }: ScanModelResultsProps) => {
aria-label={t('boards.clearSearch')} aria-label={t('boards.clearSearch')}
icon={<PiXBold />} icon={<PiXBold />}
onClick={clearSearch} onClick={clearSearch}
flexShrink={0}
/> />
</InputRightElement> </InputRightElement>
)} )}
</InputGroup> </InputGroup>
</Flex> </Flex>
</Flex> </Flex>
<Flex height="100%" layerStyle="third" borderRadius="base" p={4} mt={4} mb={4}> <Flex height="100%" layerStyle="third" borderRadius="base" p={3}>
<ScrollableContent> <ScrollableContent>
<Flex flexDir="column" gap={3}> <Flex flexDir="column" gap={3}>
{filteredResults.map((result) => ( {filteredResults.map((result) => (

View File

@ -13,9 +13,9 @@ export const InstallModels = () => {
<Heading fontSize="xl">{t('modelManager.addModel')}</Heading> <Heading fontSize="xl">{t('modelManager.addModel')}</Heading>
<Tabs variant="collapse" height="50%" display="flex" flexDir="column"> <Tabs variant="collapse" height="50%" display="flex" flexDir="column">
<TabList> <TabList>
<Tab>{t('common.simple')}</Tab> <Tab>{t('modelManager.urlOrLocalPath')}</Tab>
<Tab>{t('modelManager.huggingFace')}</Tab> <Tab>{t('modelManager.huggingFace')}</Tab>
<Tab>{t('modelManager.scan')}</Tab> <Tab>{t('modelManager.scanFolder')}</Tab>
</TabList> </TabList>
<TabPanels p={3} height="100%"> <TabPanels p={3} height="100%">
<TabPanel> <TabPanel>