Skip to content

Commit 39114b0

Browse files
Feature (UI): add model path update for external models (#8675)
* feat(ui): add model path update for external models Add ability to update file paths for externally managed models (models with absolute paths). Invoke-controlled models (with relative paths in the models directory) are excluded from this feature to prevent breaking internal model management. - Add ModelUpdatePathButton component with modal dialog - Only show button for external models (absolute path check) - Add translations for path update UI elements * Added support for Windows UNC paths in ModelView.tsx:38-41. The isExternalModel function now detects: Unix absolute paths: /home/user/models/... Windows drive paths: C:\Models\... or D:/Models/... Windows UNC paths: \\ServerName\ShareName\... or //ServerName/ShareName/... * fix(ui): validate path format in Update Path modal to prevent invalid paths When updating an external model's path, the new path is now validated to ensure it follows an absolute path format (Unix, Windows drive, or UNC). This prevents users from accidentally entering invalid paths that would cause the Update Path button to disappear, leaving them unable to correct the mistake. * fix(ui): extract isExternalModel to separate file to fix circular dependency Moves the isExternalModel utility function to its own file to break the circular dependency between ModelView.tsx and ModelUpdatePathButton.tsx. --------- Co-authored-by: Lincoln Stein <[email protected]>
1 parent 3fe5f62 commit 39114b0

File tree

4 files changed

+171
-0
lines changed

4 files changed

+171
-0
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,15 @@
884884
"reidentifySuccess": "Model reidentified successfully",
885885
"reidentifyUnknown": "Unable to identify model",
886886
"reidentifyError": "Error reidentifying model",
887+
"updatePath": "Update Path",
888+
"updatePathTooltip": "Update the file path for this model if you have moved the model files to a new location.",
889+
"updatePathDescription": "Enter the new path to the model file or directory. Use this if you have manually moved the model files on disk.",
890+
"currentPath": "Current Path",
891+
"newPath": "New Path",
892+
"newPathPlaceholder": "Enter new path...",
893+
"pathUpdated": "Model path updated successfully",
894+
"pathUpdateFailed": "Failed to update model path",
895+
"invalidPathFormat": "Path must be an absolute path (e.g., C:\\Models\\... or /home/user/models/...)",
887896
"convert": "Convert",
888897
"convertingModelBegin": "Converting Model. Please wait.",
889898
"convertToDiffusers": "Convert To Diffusers",
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {
2+
Button,
3+
Flex,
4+
FormControl,
5+
FormErrorMessage,
6+
FormLabel,
7+
Input,
8+
Modal,
9+
ModalBody,
10+
ModalCloseButton,
11+
ModalContent,
12+
ModalFooter,
13+
ModalHeader,
14+
ModalOverlay,
15+
Text,
16+
useDisclosure,
17+
} from '@invoke-ai/ui-library';
18+
import { isExternalModel } from 'features/modelManagerV2/subpanels/ModelPanel/isExternalModel';
19+
import { toast } from 'features/toast/toast';
20+
import type { ChangeEvent } from 'react';
21+
import { memo, useCallback, useMemo, useState } from 'react';
22+
import { useTranslation } from 'react-i18next';
23+
import { PiFolderOpenFill } from 'react-icons/pi';
24+
import { useUpdateModelMutation } from 'services/api/endpoints/models';
25+
import type { AnyModelConfig } from 'services/api/types';
26+
27+
interface Props {
28+
modelConfig: AnyModelConfig;
29+
}
30+
31+
export const ModelUpdatePathButton = memo(({ modelConfig }: Props) => {
32+
const { t } = useTranslation();
33+
const { isOpen, onOpen, onClose } = useDisclosure();
34+
const [updateModel, { isLoading }] = useUpdateModelMutation();
35+
const [newPath, setNewPath] = useState(modelConfig.path);
36+
37+
const handleOpen = useCallback(() => {
38+
setNewPath(modelConfig.path);
39+
onOpen();
40+
}, [modelConfig.path, onOpen]);
41+
42+
const handlePathChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
43+
setNewPath(e.target.value);
44+
}, []);
45+
46+
// Validate that the new path is still a valid external model path format
47+
const isValidPath = useMemo(() => isExternalModel(newPath.trim()), [newPath]);
48+
49+
const handleSubmit = useCallback(() => {
50+
if (!newPath.trim() || newPath === modelConfig.path || !isValidPath) {
51+
onClose();
52+
return;
53+
}
54+
55+
updateModel({
56+
key: modelConfig.key,
57+
body: { path: newPath.trim() },
58+
})
59+
.unwrap()
60+
.then(() => {
61+
toast({
62+
id: 'MODEL_PATH_UPDATED',
63+
title: t('modelManager.pathUpdated'),
64+
status: 'success',
65+
});
66+
onClose();
67+
})
68+
.catch(() => {
69+
toast({
70+
id: 'MODEL_PATH_UPDATE_FAILED',
71+
title: t('modelManager.pathUpdateFailed'),
72+
status: 'error',
73+
});
74+
});
75+
}, [newPath, modelConfig.path, modelConfig.key, updateModel, onClose, t, isValidPath]);
76+
77+
const hasChanges = newPath.trim() !== modelConfig.path;
78+
const showError = hasChanges && !isValidPath;
79+
80+
return (
81+
<>
82+
<Button
83+
onClick={handleOpen}
84+
size="sm"
85+
aria-label={t('modelManager.updatePathTooltip')}
86+
tooltip={t('modelManager.updatePathTooltip')}
87+
flexShrink={0}
88+
leftIcon={<PiFolderOpenFill />}
89+
>
90+
{t('modelManager.updatePath')}
91+
</Button>
92+
<Modal isOpen={isOpen} onClose={onClose} isCentered size="lg" useInert={false}>
93+
<ModalOverlay />
94+
<ModalContent>
95+
<ModalHeader>{t('modelManager.updatePath')}</ModalHeader>
96+
<ModalCloseButton />
97+
<ModalBody>
98+
<Flex flexDirection="column" gap={4}>
99+
<Text fontSize="sm" color="base.400">
100+
{t('modelManager.updatePathDescription')}
101+
</Text>
102+
<FormControl>
103+
<FormLabel>{t('modelManager.currentPath')}</FormLabel>
104+
<Text fontSize="sm" color="base.300" wordBreak="break-all">
105+
{modelConfig.path}
106+
</Text>
107+
</FormControl>
108+
<FormControl isInvalid={showError}>
109+
<FormLabel>{t('modelManager.newPath')}</FormLabel>
110+
<Input value={newPath} onChange={handlePathChange} placeholder={t('modelManager.newPathPlaceholder')} />
111+
{showError && <FormErrorMessage>{t('modelManager.invalidPathFormat')}</FormErrorMessage>}
112+
</FormControl>
113+
</Flex>
114+
</ModalBody>
115+
<ModalFooter>
116+
<Flex gap={2}>
117+
<Button variant="ghost" onClick={onClose}>
118+
{t('common.cancel')}
119+
</Button>
120+
<Button
121+
colorScheme="invokeYellow"
122+
onClick={handleSubmit}
123+
isLoading={isLoading}
124+
isDisabled={!hasChanges || showError}
125+
>
126+
{t('common.save')}
127+
</Button>
128+
</Flex>
129+
</ModalFooter>
130+
</ModalContent>
131+
</Modal>
132+
</>
133+
);
134+
});
135+
136+
ModelUpdatePathButton.displayName = 'ModelUpdatePathButton';

invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { memo, useMemo } from 'react';
1010
import { useTranslation } from 'react-i18next';
1111
import type { AnyModelConfig } from 'services/api/types';
1212

13+
import { isExternalModel } from './isExternalModel';
1314
import { MainModelDefaultSettings } from './MainModelDefaultSettings/MainModelDefaultSettings';
1415
import { ModelAttrView } from './ModelAttrView';
1516
import { ModelDeleteButton } from './ModelDeleteButton';
1617
import { ModelReidentifyButton } from './ModelReidentifyButton';
18+
import { ModelUpdatePathButton } from './ModelUpdatePathButton';
1719
import { RelatedModels } from './RelatedModels';
1820

1921
type Props = {
@@ -23,6 +25,9 @@ type Props = {
2325
export const ModelView = memo(({ modelConfig }: Props) => {
2426
const { t } = useTranslation();
2527

28+
// Only allow path updates for external models (not Invoke-controlled)
29+
const canUpdatePath = useMemo(() => isExternalModel(modelConfig.path), [modelConfig.path]);
30+
2631
const withSettings = useMemo(() => {
2732
if (modelConfig.type === 'main' && modelConfig.base !== 'sdxl-refiner') {
2833
return true;
@@ -44,6 +49,7 @@ export const ModelView = memo(({ modelConfig }: Props) => {
4449
return (
4550
<Flex flexDir="column" gap={4} h="full">
4651
<ModelHeader modelConfig={modelConfig}>
52+
{canUpdatePath && <ModelUpdatePathButton modelConfig={modelConfig} />}
4753
<ModelReidentifyButton modelConfig={modelConfig} />
4854
{modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && (
4955
<ModelConvertButton modelConfig={modelConfig} />
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Checks if a model path is absolute (external model) or relative (Invoke-controlled).
3+
* External models have absolute paths like "X:/ModelPath/model.safetensors" or "/home/user/models/model.safetensors".
4+
* Invoke-controlled models have relative paths like "uuid/model.safetensors".
5+
*/
6+
export const isExternalModel = (path: string): boolean => {
7+
// Unix absolute path
8+
if (path.startsWith('/')) {
9+
return true;
10+
}
11+
// Windows absolute path (e.g., "X:/..." or "X:\...")
12+
if (path.length > 1 && path[1] === ':') {
13+
return true;
14+
}
15+
// Windows UNC path (e.g., "\\ServerName\ShareName\..." or "//ServerName/ShareName/...")
16+
if (path.startsWith('\\\\') || path.startsWith('//')) {
17+
return true;
18+
}
19+
return false;
20+
};

0 commit comments

Comments
 (0)