diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index be5ecd9a9cf..894c0f81957 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -872,6 +872,14 @@ "reidentifySuccess": "Model reidentified successfully", "reidentifyUnknown": "Unable to identify model", "reidentifyError": "Error reidentifying model", + "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.", + "currentPath": "Current Path", + "newPath": "New Path", + "newPathPlaceholder": "Enter new path...", + "pathUpdated": "Model path updated successfully", + "pathUpdateFailed": "Failed to update model path", "convert": "Convert", "convertingModelBegin": "Converting Model. Please wait.", "convertToDiffusers": "Convert To Diffusers", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelUpdatePathButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelUpdatePathButton.tsx new file mode 100644 index 00000000000..7613586c9c2 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelUpdatePathButton.tsx @@ -0,0 +1,124 @@ +import { + Button, + Flex, + FormControl, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useDisclosure, +} from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFolderOpenFill } from 'react-icons/pi'; +import { useUpdateModelMutation } from 'services/api/endpoints/models'; +import type { AnyModelConfig } from 'services/api/types'; + +interface Props { + modelConfig: AnyModelConfig; +} + +export const ModelUpdatePathButton = memo(({ modelConfig }: Props) => { + const { t } = useTranslation(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [updateModel, { isLoading }] = useUpdateModelMutation(); + const [newPath, setNewPath] = useState(modelConfig.path); + + const handleOpen = useCallback(() => { + setNewPath(modelConfig.path); + onOpen(); + }, [modelConfig.path, onOpen]); + + const handlePathChange = useCallback((e: ChangeEvent) => { + setNewPath(e.target.value); + }, []); + + const handleSubmit = useCallback(() => { + if (!newPath.trim() || newPath === modelConfig.path) { + onClose(); + return; + } + + updateModel({ + key: modelConfig.key, + body: { path: newPath.trim() }, + }) + .unwrap() + .then(() => { + toast({ + id: 'MODEL_PATH_UPDATED', + title: t('modelManager.pathUpdated'), + status: 'success', + }); + onClose(); + }) + .catch(() => { + toast({ + id: 'MODEL_PATH_UPDATE_FAILED', + title: t('modelManager.pathUpdateFailed'), + status: 'error', + }); + }); + }, [newPath, modelConfig.path, modelConfig.key, updateModel, onClose, t]); + + const hasChanges = newPath.trim() !== modelConfig.path; + + return ( + <> + + + + + {t('modelManager.updatePath')} + + + + + {t('modelManager.updatePathDescription')} + + + {t('modelManager.currentPath')} + + {modelConfig.path} + + + + {t('modelManager.newPath')} + + + + + + + + + + + + + + ); +}); + +ModelUpdatePathButton.displayName = 'ModelUpdatePathButton'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx index 7ce2921623a..57bb84be70c 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -14,15 +14,36 @@ import { MainModelDefaultSettings } from './MainModelDefaultSettings/MainModelDe import { ModelAttrView } from './ModelAttrView'; import { ModelDeleteButton } from './ModelDeleteButton'; import { ModelReidentifyButton } from './ModelReidentifyButton'; +import { ModelUpdatePathButton } from './ModelUpdatePathButton'; import { RelatedModels } from './RelatedModels'; type Props = { modelConfig: AnyModelConfig; }; +/** + * Checks if a model path is absolute (external model) or relative (Invoke-controlled). + * External models have absolute paths like "X:/ModelPath/model.safetensors" or "/home/user/models/model.safetensors". + * Invoke-controlled models have relative paths like "uuid/model.safetensors". + */ +const isExternalModel = (path: string): boolean => { + // Unix absolute path + if (path.startsWith('/')) { + return true; + } + // Windows absolute path (e.g., "X:/..." or "X:\...") + if (path.length > 1 && path[1] === ':') { + return true; + } + return false; +}; + export const ModelView = memo(({ modelConfig }: Props) => { const { t } = useTranslation(); + // Only allow path updates for external models (not Invoke-controlled) + const canUpdatePath = useMemo(() => isExternalModel(modelConfig.path), [modelConfig.path]); + const withSettings = useMemo(() => { if (modelConfig.type === 'main' && modelConfig.base !== 'sdxl-refiner') { return true; @@ -44,6 +65,7 @@ export const ModelView = memo(({ modelConfig }: Props) => { return ( + {canUpdatePath && } {modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && (