From 98176d9ceff441aa2572f924b8ddd883dfe3ff25 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Thu, 15 Oct 2026 14:41:26 -0600 Subject: [PATCH] fix(models): always install in place from the configured models folder Local sources inside the models folder now always install in place; the Install in place option shows checked and disabled there, with a tooltip. --- .../frontend/webv2/public/locales/en.json | 1 + .../src/features/models/core/schemas.test.ts | 32 ++++++++++- .../webv2/src/features/models/core/schemas.ts | 27 +++++++++ .../add-models/AddModelsView.browser.test.tsx | 57 +++++++++++++++++++ .../models/ui/add-models/AddModelsView.tsx | 19 ++++++- .../models/ui/add-models/InstallOptions.tsx | 34 +++++++---- .../models/ui/add-models/ScanResults.tsx | 4 ++ 7 files changed, 159 insertions(+), 15 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index a71f0883be6..4e40c2b4ed6 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1248,6 +1248,7 @@ "installFp8Storage": "FP8 storage", "installFp8StorageTooltip": "Store the weights in FP8 to roughly halve VRAM use. Applied only where the model's loader supports it — a checkpoint that already stores FP8 weights gets it automatically, and models it cannot help (GGUF, NF4 and SDNQ among them) are left alone.", "installInPlace": "Install in place", + "installInPlaceLockedTooltip": "Models in the Invoke models folder are always installed in place.", "installJobSummary_one": "{{count}} job · no active installs", "installJobSummary_other": "{{count}} jobs · no active installs", "installJobs": "Install jobs", diff --git a/invokeai/frontend/webv2/src/features/models/core/schemas.test.ts b/invokeai/frontend/webv2/src/features/models/core/schemas.test.ts index 509d3b7b463..4af5160ae2c 100644 --- a/invokeai/frontend/webv2/src/features/models/core/schemas.test.ts +++ b/invokeai/frontend/webv2/src/features/models/core/schemas.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { isAbsoluteModelPath, mainDefaultSettingsSchema, modelPathSchema, resolveModelAbsolutePath } from './schemas'; +import { + isAbsoluteModelPath, + isInModelsDir, + mainDefaultSettingsSchema, + modelPathSchema, + resolveModelAbsolutePath, +} from './schemas'; describe('isAbsoluteModelPath', () => { it.each([ @@ -84,3 +90,27 @@ describe('mainDefaultSettingsSchema', () => { expect(result.success).toBe(false); }); }); + +describe('isInModelsDir', () => { + it.each([ + ['/data/models', '/data/models'], + ['/data/models/', '/data/models'], + ['/data/models/sdxl/model.safetensors', '/data/models'], + ['/data/models/sdxl/model.safetensors', '/data/models/'], + ['C:\\Invoke\\Models\\sdxl.safetensors', 'C:/invoke/models'], + ['c:/invoke/models', 'C:/Invoke/Models'], + ['\\\\NAS\\Models\\sdxl.safetensors', '//nas/models'], + ])('places %s in the models folder %s', (path, modelsDir) => { + expect(isInModelsDir(path, modelsDir)).toBe(true); + }); + + it.each([ + ['/data/models-extra/model.safetensors', '/data/models'], + ['/data/Models/model.safetensors', '/data/models'], + ['/data', '/data/models'], + ['/data/models/../elsewhere/model.safetensors', '/data/models'], + ['/data/models/model.safetensors', null], + ])('keeps %s outside the models folder %s', (path, modelsDir) => { + expect(isInModelsDir(path, modelsDir)).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/models/core/schemas.ts b/invokeai/frontend/webv2/src/features/models/core/schemas.ts index 06ffb813a4c..1de52f932bb 100644 --- a/invokeai/frontend/webv2/src/features/models/core/schemas.ts +++ b/invokeai/frontend/webv2/src/features/models/core/schemas.ts @@ -92,6 +92,33 @@ export const isAbsoluteModelPath = (path: string): boolean => export const resolveModelAbsolutePath = (path: string, modelsDir: string | null): string => isAbsoluteModelPath(path) || !modelsDir ? path : `${modelsDir.replace(/\/+$/, '')}/${path}`; +const comparablePath = (path: string): string => path.trim().replaceAll('\\', '/').replace(/\/+$/, ''); + +/** + * Whether `path` is the models directory or inside it. The comparison is textual — the server reports the directory + * resolved, so a symlinked or `~` spelling of it does not match, and a path with `..` segments never does. Windows + * drive and UNC paths compare case-insensitively. + */ +export const isInModelsDir = (path: string, modelsDir: string | null): boolean => { + if (!modelsDir) { + return false; + } + + let dir = comparablePath(modelsDir); + let candidate = comparablePath(path); + + if (/(^|\/)\.\.(\/|$)/.test(candidate)) { + return false; + } + + if (/^([A-Za-z]:\/|\/\/)/.test(dir)) { + dir = dir.toLowerCase(); + candidate = candidate.toLowerCase(); + } + + return dir !== '' && (candidate === dir || candidate.startsWith(`${dir}/`)); +}; + /** * Replacement path for a relocated model. Only absolute paths are accepted: * managed models store paths relative to the models directory and must not be diff --git a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx index da0eaeaa834..11f81705c68 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx @@ -349,6 +349,63 @@ describe('AddModelsView models folder scan', () => { } ); + it('always installs from inside the models folder in place, showing the option on and disabled', async () => { + const modelsDir = '/opt/invokeai/models'; + setModelsSnapshotForTests({ models: [], modelsDir, status: 'loaded' }); + installActions.install.mockReset(); + installActions.installMany.mockReset(); + await mount(); + + const searchBox = document.querySelector('input[aria-label="models.searchOrAdd"]')!; + const inplaceLabel = () => + [...document.querySelectorAll('label')].find((label) => label.textContent === 'models.installInPlace')!; + const inplaceInput = () => inplaceLabel().querySelector('input')!; + + // Outside the folder the option is the user's to turn off. + const outside = '/home/user/downloads/outside.safetensors'; + await act(() => userEvent.fill(searchBox, outside)); + await act(() => userEvent.click(inplaceLabel())); + expect(inplaceInput().checked).toBe(false); + await act(() => userEvent.click(buttonNamed('models.pull')!)); + expect(installActions.install).toHaveBeenLastCalledWith( + expect.objectContaining({ inplace: false, source: outside }) + ); + + const inside = `${modelsDir}/loras/inside.safetensors`; + await act(() => userEvent.fill(searchBox, inside)); + expect(inplaceInput().checked).toBe(true); + expect(inplaceInput().disabled).toBe(true); + expect(document.getElementById(inplaceInput().getAttribute('aria-describedby')!)?.textContent).toBe( + 'models.installInPlaceLockedTooltip' + ); + await act(() => userEvent.click(buttonNamed('models.pull')!)); + expect(installActions.install).toHaveBeenLastCalledWith(expect.objectContaining({ inplace: true, source: inside })); + + // Leaving the folder restores the user's own choice. + await act(() => userEvent.fill(searchBox, outside)); + expect(inplaceInput().checked).toBe(false); + expect(inplaceInput().disabled).toBe(false); + + await act(() => + updateModelsUi({ + // A symlinked spelling of the folder: the server reports results under the resolved path. + scan: { + path: '/home/user/invokeai/models', + results: [ + { is_installed: false, path: `${modelsDir}/main/a.safetensors` }, + { is_installed: false, path: `${modelsDir}/main/b.safetensors` }, + ], + }, + }) + ); + expect(inplaceInput().checked).toBe(true); + expect(inplaceInput().disabled).toBe(true); + await act(() => userEvent.click(buttonNamed('models.installAllCount')!)); + + const [requests] = installActions.installMany.mock.lastCall as [{ inplace?: boolean }[]]; + expect(requests.map((request) => request.inplace)).toEqual([true, true]); + }); + it('stops a models folder scan without reporting a failure, and can scan again', async () => { setModelsSnapshotForTests({ models: [], modelsDir: '/opt/invokeai/models', status: 'loaded' }); const scan = holdScan(); diff --git a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx index 660c2cd8a74..9317501c3a6 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx @@ -4,6 +4,7 @@ import type { ElementType } from 'react'; import { Box, Flex, HStack, Icon, Input, InputGroup, Spinner, Stack, Text } from '@chakra-ui/react'; import { collectBases, collectTypes } from '@features/models/core/library'; +import { isInModelsDir } from '@features/models/core/schemas'; import { DEFAULT_STARTER_MODEL_FILTERS, filterStarterModels, @@ -14,6 +15,7 @@ import { ensureExternalProvidersLoaded, useExternalProvidersSelector, } from '@features/models/data/externalProvidersStore'; +import { useModelsSelector } from '@features/models/data/modelsStore'; import { ensureStartersLoaded, useStartersSelector } from '@features/models/data/startersStore'; import { clearAddModelsSeeds, @@ -90,6 +92,10 @@ export const AddModelsView = () => { const [query, setQuery] = useState(getAddModelsSeed); const [accessToken, setAccessToken] = useState(''); const [inplace, setInplace] = useState(true); + const modelsDir = useModelsSelector((snapshot) => snapshot.modelsDir); + // Sources in the models folder always install in place; otherwise the install would move their files into a new + // managed folder within it. + const inplaceFor = (source: string) => inplace || isInModelsDir(source, modelsDir); const [fp8Storage, setFp8Storage] = useState(false); const { isBusy: isPulling, run: runPull } = useScopedAction(); const { isBusy: isScanning, run: runScan } = useScopedAction(); @@ -269,7 +275,7 @@ export const AddModelsView = () => { (await install({ accessToken: token, config: installConfig, - inplace: kind.looksLocal ? inplace : undefined, + inplace: kind.looksLocal ? inplaceFor(trimmed) : undefined, source: trimmed, })) && isAccountScopeCurrent(owner) @@ -398,6 +404,7 @@ export const AddModelsView = () => { @@ -468,12 +475,18 @@ export const AddModelsView = () => { 0 && scan.results.every((result) => isInModelsDir(result.path, modelsDir)) + } pendingSources={pendingSources} scan={scan} onClear={() => updateModelsUi({ scan: null })} - onInstall={(path) => void install({ config: installConfig, inplace, source: path })} + onInstall={(path) => void install({ config: installConfig, inplace: inplaceFor(path), source: path })} onInstallAll={(paths) => - void installAllSources(paths.map((path) => ({ config: installConfig, inplace, source: path }))) + void installAllSources( + paths.map((path) => ({ config: installConfig, inplace: inplaceFor(path), source: path })) + ) } onSetFp8Storage={setFp8Storage} onSetInplace={setInplace} diff --git a/invokeai/frontend/webv2/src/features/models/ui/add-models/InstallOptions.tsx b/invokeai/frontend/webv2/src/features/models/ui/add-models/InstallOptions.tsx index 416e7ee3fa6..57d2ffb786e 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/add-models/InstallOptions.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/add-models/InstallOptions.tsx @@ -11,32 +11,44 @@ import { useTranslation } from 'react-i18next'; export const InstallOptions = ({ fp8Storage, inplace, + inplaceLocked = false, onSetFp8Storage, onSetInplace, }: { fp8Storage: boolean; /** Omitted, with its setter, for sources that cannot be installed in place (URLs, repos). */ inplace?: boolean; + /** The sources are in the models folder, which always installs in place: shown on and disabled. */ + inplaceLocked?: boolean; onSetFp8Storage: (fp8Storage: boolean) => void; onSetInplace?: (inplace: boolean) => void; }) => { const { t } = useTranslation(); - // The tooltip describes its trigger, the label; the focusable input needs the same text to be announced. + // A tooltip describes its trigger, the label; the focusable input needs the same text to be announced. const fp8StorageDescriptionId = useId(); + const inplaceLockedDescriptionId = useId(); return ( {inplace === undefined || onSetInplace === undefined ? null : ( - onSetInplace(event.checked === true)} - > - - - {t('models.installInPlace')} - + <> + + onSetInplace(event.checked === true)} + > + + + {t('models.installInPlace')} + + + {inplaceLocked ? ( + {t('models.installInPlaceLockedTooltip')} + ) : null} + )} result.path; export const ScanResults = ({ fp8Storage, inplace, + inplaceLocked, onClear, onInstall, onInstallAll, @@ -33,6 +34,8 @@ export const ScanResults = ({ }: { fp8Storage: boolean; inplace: boolean; + /** Every result is in the models folder, so each installs in place. */ + inplaceLocked?: boolean; onClear: () => void; onInstall: (path: string) => void; /** Bulk path: the parent queues silently and emits one summary toast. */ @@ -83,6 +86,7 @@ export const ScanResults = ({