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
10 changes: 9 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1164,7 +1164,15 @@
"installingXModels_other": "Installing {{count}} models",
"skippingXDuplicates_one": ", skipping {{count}} duplicate",
"skippingXDuplicates_other": ", skipping {{count}} duplicates",
"manageModels": "Manage Models"
"manageModels": "Manage Models",
"exportSettings": "Export Settings",
"importSettings": "Import Settings",
"settingsExported": "Model settings exported",
"settingsImported": "Model settings imported",
"settingsImportedPartial": "Model settings partially imported. Incompatible settings were skipped: {{fields}}",
"settingsImportFailed": "Failed to import model settings",
"settingsImportIncompatible": "The settings file contains no compatible settings for this model type",
"settingsImportInvalidFile": "Invalid settings file"
},
"models": {
"addLora": "Add LoRA",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsMod
import { DefaultPreprocessor } from 'features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/DefaultPreprocessor';
import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { memo, useCallback, useEffect } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -32,6 +32,10 @@ export const ControlAdapterModelDefaultSettings = memo(({ modelConfig }: Props)
defaultValues: defaultSettingsDefaults,
});

useEffect(() => {
reset(defaultSettingsDefaults);
}, [defaultSettingsDefaults, reset]);

const onSubmit = useCallback<SubmitHandler<ControlAdapterModelDefaultSettingsFormData>>(
(data) => {
const body = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { selectSelectedModelKey } from 'features/modelManagerV2/store/modelManag
import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings';
import { toast } from 'features/toast/toast';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useEffect, useMemo } from 'react';
import type { Control, SubmitHandler } from 'react-hook-form';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -80,6 +80,10 @@ export const EncoderModelSettings = memo(({ modelConfig }: Props) => {
defaultValues: settingsDefaults,
});

useEffect(() => {
reset(settingsDefaults);
}, [settingsDefaults, reset]);

const onSubmit = useCallback<SubmitHandler<EncoderModelSettingsFormData>>(
(data) => {
if (!selectedModelKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useLoRAModelDefaultSettings } from 'features/modelManagerV2/hooks/useLo
import { DefaultWeight } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight';
import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { memo, useCallback, useEffect } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -32,6 +32,10 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => {
defaultValues: defaultSettingsDefaults,
});

useEffect(() => {
reset(defaultSettingsDefaults);
}, [defaultSettingsDefaults, reset]);

const onSubmit = useCallback<SubmitHandler<LoRAModelDefaultSettingsFormData>>(
(data) => {
const body = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DefaultWidth } from 'features/modelManagerV2/subpanels/ModelPanel/MainM
import type { ParameterScheduler } from 'features/parameters/types/parameterSchemas';
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { toast } from 'features/toast/toast';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useEffect, useMemo } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -65,6 +65,10 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => {
defaultValues: defaultSettingsDefaults,
});

useEffect(() => {
reset(defaultSettingsDefaults);
}, [defaultSettingsDefaults, reset]);

const onSubmit = useCallback<SubmitHandler<MainModelDefaultSettingsFormData>>(
(data) => {
if (!selectedModelKey) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { IconButton } from '@invoke-ai/ui-library';
import { toast } from 'features/toast/toast';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold } from 'react-icons/pi';
import type { AnyModelConfig } from 'services/api/types';

type Props = {
modelConfig: AnyModelConfig;
};

const buildExportData = (modelConfig: AnyModelConfig): Record<string, unknown> => {
const data: Record<string, unknown> = {};

if (
'default_settings' in modelConfig &&
modelConfig.default_settings !== undefined &&
modelConfig.default_settings !== null
) {
data.default_settings = modelConfig.default_settings;
}

if (
'trigger_phrases' in modelConfig &&
modelConfig.trigger_phrases !== undefined &&
modelConfig.trigger_phrases !== null
) {
data.trigger_phrases = modelConfig.trigger_phrases;
}

if ('cpu_only' in modelConfig && modelConfig.cpu_only !== null) {
data.cpu_only = modelConfig.cpu_only;
}

return data;
};

const sanitizeFilename = (name: string): string => {
return name.replace(/[<>:"/\\|?*]/g, '_');
};

export const ModelSettingsExportButton = memo(({ modelConfig }: Props) => {
const { t } = useTranslation();

const hasExportableData = useMemo(() => Object.keys(buildExportData(modelConfig)).length > 0, [modelConfig]);

const handleExport = useCallback(() => {
const data = buildExportData(modelConfig);
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const filename = `${sanitizeFilename(modelConfig.name)}.json`;

const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);

toast({
id: 'SETTINGS_EXPORTED',
title: t('modelManager.settingsExported'),
status: 'success',
});
}, [modelConfig, t]);

return (
<IconButton
size="sm"
icon={<PiDownloadSimpleBold />}
aria-label={t('modelManager.exportSettings')}
tooltip={t('modelManager.exportSettings')}
onClick={handleExport}
isDisabled={!hasExportableData}
/>
);
});

ModelSettingsExportButton.displayName = 'ModelSettingsExportButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { IconButton } from '@invoke-ai/ui-library';
import { toast } from 'features/toast/toast';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiUploadSimpleBold } from 'react-icons/pi';
import { useUpdateModelMutation } from 'services/api/endpoints/models';
import type { AnyModelConfig } from 'services/api/types';

const validateImportData = (data: unknown): data is Record<string, unknown> => {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
return false;
}

const obj = data as Record<string, unknown>;

if ('trigger_phrases' in obj && obj.trigger_phrases !== undefined) {
if (!Array.isArray(obj.trigger_phrases) || !obj.trigger_phrases.every((p) => typeof p === 'string')) {
return false;
}
}

if ('default_settings' in obj && obj.default_settings !== undefined) {
if (
typeof obj.default_settings !== 'object' ||
obj.default_settings === null ||
Array.isArray(obj.default_settings)
) {
return false;
}
}

if ('cpu_only' in obj && obj.cpu_only !== undefined) {
if (typeof obj.cpu_only !== 'boolean') {
return false;
}
}

return true;
};

type Props = {
modelConfig: AnyModelConfig;
};

export const ModelSettingsImportButton = memo(({ modelConfig }: Props) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [updateModel] = useUpdateModelMutation();

const applySettings = useCallback(
async (data: Record<string, unknown>) => {
const body: Record<string, unknown> = {};
const skippedFields: string[] = [];

const importableFields = ['default_settings', 'trigger_phrases', 'cpu_only'] as const;

for (const field of importableFields) {
if (!(field in data) || data[field] === undefined || data[field] === null) {
continue;
}
if (field in modelConfig) {
body[field] = data[field];
} else {
skippedFields.push(field);
}
}

if (Object.keys(body).length === 0) {
if (skippedFields.length > 0) {
toast({
id: 'SETTINGS_IMPORT_INCOMPATIBLE',
title: t('modelManager.settingsImportIncompatible'),
status: 'warning',
});
}
return;
}

await updateModel({
key: modelConfig.key,
body,
})
.unwrap()
.then(() => {
if (skippedFields.length > 0) {
toast({
id: 'SETTINGS_IMPORTED',
title: t('modelManager.settingsImportedPartial', { fields: skippedFields.join(', ') }),
status: 'warning',
});
} else {
toast({
id: 'SETTINGS_IMPORTED',
title: t('modelManager.settingsImported'),
status: 'success',
});
}
})
.catch((_error) => {
toast({
id: 'SETTINGS_IMPORT_FAILED',
title: t('modelManager.settingsImportFailed'),
status: 'error',
});
});
},
[modelConfig, updateModel, t]
);

const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}

const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
if (!validateImportData(json)) {
toast({
id: 'SETTINGS_IMPORT_INVALID',
title: t('modelManager.settingsImportInvalidFile'),
status: 'error',
});
return;
}
applySettings(json);
} catch {
toast({
id: 'SETTINGS_IMPORT_INVALID',
title: t('modelManager.settingsImportInvalidFile'),
status: 'error',
});
}
};
reader.onerror = () => {
toast({
id: 'SETTINGS_IMPORT_INVALID',
title: t('modelManager.settingsImportInvalidFile'),
status: 'error',
});
};
reader.readAsText(file);

// Reset the input so the same file can be re-selected
e.target.value = '';
},
[applySettings, t]
);

const handleClick = useCallback(() => {
fileInputRef.current?.click();
}, []);

return (
<>
<IconButton
size="sm"
icon={<PiUploadSimpleBold />}
aria-label={t('modelManager.importSettings')}
tooltip={t('modelManager.importSettings')}
onClick={handleClick}
/>
<input ref={fileInputRef} type="file" accept=".json" onChange={handleFileChange} style={{ display: 'none' }} />
</>
);
});

ModelSettingsImportButton.displayName = 'ModelSettingsImportButton';
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { LoRAModelDefaultSettings } from 'features/modelManagerV2/subpanels/Mode
import { ModelConvertButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelConvertButton';
import { ModelEditButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelEditButton';
import { ModelHeader } from 'features/modelManagerV2/subpanels/ModelPanel/ModelHeader';
import { ModelSettingsExportButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton';
import { ModelSettingsImportButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton';
import { TriggerPhrases } from 'features/modelManagerV2/subpanels/ModelPanel/TriggerPhrases';
import { filesize } from 'filesize';
import { memo, useMemo } from 'react';
Expand Down Expand Up @@ -88,6 +90,8 @@ export const ModelView = memo(({ modelConfig }: Props) => {
{canManageModels && modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && (
<ModelConvertButton modelConfig={modelConfig} />
)}
{withSettings && <ModelSettingsImportButton modelConfig={modelConfig} />}
{withSettings && <ModelSettingsExportButton modelConfig={modelConfig} />}
{canManageModels && <ModelEditButton />}
{canManageModels && <ModelDeleteButton modelConfig={modelConfig} />}
</ModelHeader>
Expand Down