Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions invokeai/frontend/webv2/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 31 additions & 1 deletion invokeai/frontend/webv2/src/features/models/core/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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);
});
});
27 changes: 27 additions & 0 deletions invokeai/frontend/webv2/src/features/models/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>('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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -398,6 +404,7 @@ export const AddModelsView = () => {
<InstallOptions
fp8Storage={fp8Storage}
inplace={kind.localKind === 'file' ? inplace : undefined}
inplaceLocked={kind.localKind === 'file' && isInModelsDir(trimmed, modelsDir)}
onSetFp8Storage={setFp8Storage}
onSetInplace={setInplace}
/>
Expand Down Expand Up @@ -468,12 +475,18 @@ export const AddModelsView = () => {
<ScanResults
fp8Storage={fp8Storage}
inplace={inplace}
// Results carry resolved paths, so a symlinked spelling of the folder still locks.
inplaceLocked={
scan.results.length > 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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<HStack gap="3">
{inplace === undefined || onSetInplace === undefined ? null : (
<Checkbox.Root
checked={inplace}
colorPalette="accent"
size="sm"
onCheckedChange={(event) => onSetInplace(event.checked === true)}
>
<Checkbox.HiddenInput />
<Checkbox.Control />
<Checkbox.Label fontSize="xs">{t('models.installInPlace')}</Checkbox.Label>
</Checkbox.Root>
<>
<Tooltip content={t('models.installInPlaceLockedTooltip')} disabled={!inplaceLocked}>
<Checkbox.Root
checked={inplace || inplaceLocked}
colorPalette="accent"
disabled={inplaceLocked}
size="sm"
onCheckedChange={(event) => onSetInplace(event.checked === true)}
>
<Checkbox.HiddenInput aria-describedby={inplaceLocked ? inplaceLockedDescriptionId : undefined} />
<Checkbox.Control />
<Checkbox.Label fontSize="xs">{t('models.installInPlace')}</Checkbox.Label>
</Checkbox.Root>
</Tooltip>
{inplaceLocked ? (
<VisuallyHidden id={inplaceLockedDescriptionId}>{t('models.installInPlaceLockedTooltip')}</VisuallyHidden>
) : null}
</>
)}
<Tooltip content={t('models.installFp8StorageTooltip')}>
<Checkbox.Root
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const pathOf = (result: FoundModel): string => result.path;
export const ScanResults = ({
fp8Storage,
inplace,
inplaceLocked,
onClear,
onInstall,
onInstallAll,
Expand All @@ -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. */
Expand Down Expand Up @@ -83,6 +86,7 @@ export const ScanResults = ({
<InstallOptions
fp8Storage={fp8Storage}
inplace={inplace}
inplaceLocked={inplaceLocked}
onSetFp8Storage={onSetFp8Storage}
onSetInplace={onSetInplace}
/>
Expand Down
Loading