Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
25640ea
feat(ui): include metadata and thumbnail in model settings export
Pfannkuchensack May 16, 2026
0126857
test(ui): add unit tests for model settings export/import helpers
Pfannkuchensack May 16, 2026
b6238cd
docs: document model settings export/import in concepts/models
Pfannkuchensack May 16, 2026
98abd10
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
Pfannkuchensack May 16, 2026
b6d7461
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
Pfannkuchensack May 25, 2026
a2190eb
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto May 28, 2026
2b7eb10
fix(mm): sync ModelImageUpload thumbnail with model_image prop
Pfannkuchensack May 28, 2026
14ab6d0
Merge branch 'feat/model-settings-export-metadata-thumbnail' of https…
Pfannkuchensack May 28, 2026
b652941
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto May 29, 2026
14c25fb
docs: remove duplicate model settings export/import section
Pfannkuchensack May 30, 2026
5b0a2ca
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
Pfannkuchensack Jun 5, 2026
995e1a5
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
Pfannkuchensack Jun 15, 2026
3198d9f
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto Jun 29, 2026
a7f932c
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto Jul 1, 2026
75111d2
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto Jul 1, 2026
880397e
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
Pfannkuchensack Jul 2, 2026
70059cc
fix(ui): avoid cascading render in ModelImageUpload
Pfannkuchensack Jul 2, 2026
0d54ab9
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto Jul 6, 2026
8731718
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
Pfannkuchensack Jul 9, 2026
3f2c3d2
Merge branch 'main' into feat/model-settings-export-metadata-thumbnail
JPPhoto Jul 10, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ type Props = {

const ModelImageUpload = ({ model_key, model_image }: Props) => {
const [image, setImage] = useState<string | null>(model_image || null);
const [prevModelImage, setPrevModelImage] = useState(model_image);
const { t } = useTranslation();

// Sync local state when the model_image prop changes (e.g. switching models) without a cascading effect.
if (model_image !== prevModelImage) {
setPrevModelImage(model_image);
setImage(model_image || null);
}

const [updateModelImage, request] = useUpdateModelImageMutation();
const [deleteModelImage] = useDeleteModelImageMutation();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,51 +1,33 @@
import { IconButton } from '@invoke-ai/ui-library';
import { toast } from 'features/toast/toast';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold } from 'react-icons/pi';
import type { AnyModelConfigWithExternal } from 'services/api/types';

import { buildExportData, fetchImageAsDataUrl, sanitizeFilename } from './modelSettingsIO';

type Props = {
modelConfig: AnyModelConfigWithExternal;
};

const buildExportData = (modelConfig: AnyModelConfigWithExternal): 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 handleExport = useCallback(async () => {
const data = buildExportData(modelConfig);

if (
'cover_image' in modelConfig &&
typeof modelConfig.cover_image === 'string' &&
modelConfig.cover_image.length > 0
) {
const dataUrl = await fetchImageAsDataUrl(modelConfig.cover_image);
if (dataUrl) {
data.cover_image = dataUrl;
}
}

const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
Expand Down Expand Up @@ -73,7 +55,6 @@ export const ModelSettingsExportButton = memo(({ modelConfig }: Props) => {
aria-label={t('modelManager.exportSettings')}
tooltip={t('modelManager.exportSettings')}
onClick={handleExport}
isDisabled={!hasExportableData}
/>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,10 @@ 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 { useUpdateModelImageMutation, useUpdateModelMutation } from 'services/api/endpoints/models';
import type { AnyModelConfigWithExternal } 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;
};
import { dataUrlToFile, isImageDataUrl, validateImportData } from './modelSettingsIO';

type Props = {
modelConfig: AnyModelConfigWithExternal;
Expand All @@ -47,13 +17,21 @@ export const ModelSettingsImportButton = memo(({ modelConfig }: Props) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [updateModel] = useUpdateModelMutation();
const [updateModelImage] = useUpdateModelImageMutation();

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;
const importableFields = [
'name',
'description',
'source_url',
'default_settings',
'trigger_phrases',
'cpu_only',
] as const;

for (const field of importableFields) {
if (!(field in data) || data[field] === undefined || data[field] === null) {
Expand All @@ -66,7 +44,12 @@ export const ModelSettingsImportButton = memo(({ modelConfig }: Props) => {
}
}

if (Object.keys(body).length === 0) {
const coverImageDataUrl =
'cover_image' in data && typeof data.cover_image === 'string' && isImageDataUrl(data.cover_image)
? data.cover_image
: null;

if (Object.keys(body).length === 0 && !coverImageDataUrl) {
if (skippedFields.length > 0) {
toast({
id: 'SETTINGS_IMPORT_INCOMPATIBLE',
Expand All @@ -77,35 +60,62 @@ export const ModelSettingsImportButton = memo(({ modelConfig }: Props) => {
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) => {
let appliedAnything = false;
if (Object.keys(body).length > 0) {
try {
await updateModel({
key: modelConfig.key,
body,
}).unwrap();
appliedAnything = true;
} catch {
toast({
id: 'SETTINGS_IMPORT_FAILED',
title: t('modelManager.settingsImportFailed'),
status: 'error',
});
return;
}
}

if (coverImageDataUrl) {
const imageFile = dataUrlToFile(coverImageDataUrl, `${modelConfig.key}.png`);
if (!imageFile) {
skippedFields.push('cover_image');
} else {
try {
await updateModelImage({ key: modelConfig.key, image: imageFile }).unwrap();
appliedAnything = true;
} catch {
skippedFields.push('cover_image');
}
}
}

if (!appliedAnything) {
toast({
id: 'SETTINGS_IMPORT_FAILED',
title: t('modelManager.settingsImportFailed'),
status: 'error',
});
return;
}

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',
});
}
},
[modelConfig, updateModel, t]
[modelConfig, updateModel, updateModelImage, t]
);

const handleFileChange = useCallback(
Expand Down
Loading
Loading