diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c28df6ee383..2db971d06a6 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -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", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx index 92d509011cc..a5e8f10a4bc 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx @@ -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'; @@ -32,6 +32,10 @@ export const ControlAdapterModelDefaultSettings = memo(({ modelConfig }: Props) defaultValues: defaultSettingsDefaults, }); + useEffect(() => { + reset(defaultSettingsDefaults); + }, [defaultSettingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { const body = { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx index bf1690359e5..e10766214f4 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx @@ -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'; @@ -80,6 +80,10 @@ export const EncoderModelSettings = memo(({ modelConfig }: Props) => { defaultValues: settingsDefaults, }); + useEffect(() => { + reset(settingsDefaults); + }, [settingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { if (!selectedModelKey) { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx index d2f55540afa..2f509caa726 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx @@ -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'; @@ -32,6 +32,10 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { defaultValues: defaultSettingsDefaults, }); + useEffect(() => { + reset(defaultSettingsDefaults); + }, [defaultSettingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { const body = { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx index 8497eee02e1..dd944897b24 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx @@ -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'; @@ -65,6 +65,10 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => { defaultValues: defaultSettingsDefaults, }); + useEffect(() => { + reset(defaultSettingsDefaults); + }, [defaultSettingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { if (!selectedModelKey) { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx new file mode 100644 index 00000000000..c5f2ae0a0b1 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx @@ -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 => { + const data: Record = {}; + + 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 ( + } + aria-label={t('modelManager.exportSettings')} + tooltip={t('modelManager.exportSettings')} + onClick={handleExport} + isDisabled={!hasExportableData} + /> + ); +}); + +ModelSettingsExportButton.displayName = 'ModelSettingsExportButton'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx new file mode 100644 index 00000000000..474afcd74ea --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx @@ -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 => { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return false; + } + + const obj = data as Record; + + 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(null); + const [updateModel] = useUpdateModelMutation(); + + const applySettings = useCallback( + async (data: Record) => { + const body: Record = {}; + 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) => { + 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 ( + <> + } + aria-label={t('modelManager.importSettings')} + tooltip={t('modelManager.importSettings')} + onClick={handleClick} + /> + + + ); +}); + +ModelSettingsImportButton.displayName = 'ModelSettingsImportButton'; 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 6e114bb252d..f29846e6f82 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -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'; @@ -88,6 +90,8 @@ export const ModelView = memo(({ modelConfig }: Props) => { {canManageModels && modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && ( )} + {withSettings && } + {withSettings && } {canManageModels && } {canManageModels && }