diff --git a/locales/de.json b/locales/de.json index db83bcbe..eadb5d06 100644 --- a/locales/de.json +++ b/locales/de.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRoot": "Embedding-Stammordner", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "Rezepte-Speicherpfad", "recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}", "unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}", "embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})", "mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht", "mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}", diff --git a/locales/en.json b/locales/en.json index c5e45b63..1f32af8f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", "defaultEmbeddingRoot": "Embedding Root", "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", + "defaultVaeRoot": "VAE Root", + "defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "Upscaler Root", + "defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "Text Encoder Root", + "defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "CLIP Vision Root", + "defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "ControlNet Root", + "defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "Recipes Storage Path", "recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Failed to load checkpoint roots: {message}", "unetRootsFailed": "Failed to load diffusion model roots: {message}", "embeddingRootsFailed": "Failed to load embedding roots: {message}", + "otherRootsFailed": "Failed to load other model roots: {message}", "mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})", "mappingsCleared": "Base model path mappings cleared", "mappingSaveFailed": "Failed to save base model mappings: {message}", diff --git a/locales/es.json b/locales/es.json index 7d32f6ac..fe83b1f9 100644 --- a/locales/es.json +++ b/locales/es.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos", "defaultEmbeddingRoot": "Raíz de embedding", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "Ruta de almacenamiento de recetas", "recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}", "unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}", "embeddingRootsFailed": "Error al cargar raíces de embedding: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})", "mappingsCleared": "Mapeos de rutas de modelo base limpiados", "mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}", diff --git a/locales/fr.json b/locales/fr.json index 9d3464f3..dc69d10c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements", "defaultEmbeddingRoot": "Racine Embedding", "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "Chemin de stockage des Recipes", "recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}", "unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}", "embeddingRootsFailed": "Échec du chargement des racines embedding : {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})", "mappingsCleared": "Mappages de chemin de modèle de base effacés", "mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}", diff --git a/locales/he.json b/locales/he.json index d0da7ac1..cb91c56a 100644 --- a/locales/he.json +++ b/locales/he.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות", "defaultEmbeddingRoot": "תיקיית שורש Embedding", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "נתיב אחסון מתכונים", "recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}", "unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}", "embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})", "mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו", "mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}", diff --git a/locales/ja.json b/locales/ja.json index afc1edbb..69339323 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定", "defaultEmbeddingRoot": "Embeddingルート", "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "レシピ保存先", "recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}", "unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}", "embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)", "mappingsCleared": "ベースモデルパスマッピングがクリアされました", "mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}", diff --git a/locales/ko.json b/locales/ko.json index b32d16da..4215737e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다", "defaultEmbeddingRoot": "Embedding 루트", "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "레시피 저장 경로", "recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}", "unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}", "embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)", "mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다", "mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}", diff --git a/locales/ru.json b/locales/ru.json index bbdbd5a7..6bf627b4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRoot": "Корневая папка Embedding", "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "Путь хранения рецептов", "recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}", "unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}", "embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})", "mappingsCleared": "Сопоставления путей базовых моделей очищены", "mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 817bdd54..c3f19342 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录", "defaultEmbeddingRoot": "Embedding 根目录", "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "配方存储路径", "recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}", "unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}", "embeddingRootsFailed": "加载 Embedding 根目录失败:{message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "基础模型路径映射已更新({count} 条映射)", "mappingsCleared": "基础模型路径映射已清除", "mappingSaveFailed": "保存基础模型映射失败:{message}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a79b4c45..2b917517 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄", "defaultEmbeddingRoot": "Embedding 根目錄", "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", + "defaultVaeRoot": "[TODO: Translate] VAE Root", + "defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "[TODO: Translate] Upscaler Root", + "defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root", + "defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root", + "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "[TODO: Translate] ControlNet Root", + "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "配方儲存路徑", "recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}", "unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}", "embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)", "mappingsCleared": "基礎模型路徑對應已清除", "mappingSaveFailed": "儲存基礎模型對應失敗:{message}", diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index d1bce2cc..7fd6d508 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -146,6 +146,7 @@ export const MODEL_SPECIFIC_ENDPOINTS = { }, [MODEL_TYPES.OTHER]: { metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`, + roots_by_subtype: `/api/lm/${MODEL_TYPES.OTHER}/roots_by_subtype`, } }; diff --git a/static/js/api/otherApi.js b/static/js/api/otherApi.js index eecf72d2..d42425c0 100644 --- a/static/js/api/otherApi.js +++ b/static/js/api/otherApi.js @@ -4,4 +4,42 @@ import { BaseModelApiClient } from './baseModelApi.js'; * Other-models-specific API client (VAE, upscalers, text encoders, etc.) */ export class OtherApiClient extends BaseModelApiClient { + /** + * Get other-model roots, optionally narrowed to one sub_type + * (vae/upscaler/text_encoder/clip_vision/controlnet). + * + * Without a sub_type this falls back to the merged roots list + * (GET /api/lm/other/roots); with one it reads the grouped + * roots_by_subtype map and extracts the matching list. + */ + async fetchModelRoots(subType = null) { + if (!subType) { + return super.fetchModelRoots(); + } + + const data = await this.fetchRootsBySubType(); + const groupedRoots = data.roots_by_subtype || {}; + return { + success: data.success !== false, + roots: groupedRoots[subType] || [], + }; + } + + /** + * Get other-model roots grouped by sub_type. + * GET /api/lm/other/roots_by_subtype + * -> { success, roots_by_subtype: {sub_type: [...]} } + */ + async fetchRootsBySubType() { + try { + const response = await fetch(this.apiConfig.endpoints.specific.roots_by_subtype); + if (!response.ok) { + throw new Error('Failed to fetch other-model roots by sub_type'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching other-model roots by sub_type:', error); + throw error; + } + } } diff --git a/static/js/components/controls/OtherControls.js b/static/js/components/controls/OtherControls.js index e7a19388..c9bb6d60 100644 --- a/static/js/components/controls/OtherControls.js +++ b/static/js/components/controls/OtherControls.js @@ -2,6 +2,7 @@ import { PageControls } from './PageControls.js'; import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; import { showToast } from '../../utils/uiHelpers.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * OtherControls class - Extends PageControls for the Other Models page @@ -39,6 +40,11 @@ export class OtherControls extends PageControls { return await getModelApiClient().fetchCivitaiMetadata(); }, + // Add show download modal functionality + showDownloadModal: () => { + downloadManager.showDownloadModal(); + }, + toggleBulkMode: () => { if (window.bulkManager) { window.bulkManager.toggleBulkMode(); diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 6831ee64..885becef 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -8,6 +8,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { translate } from '../utils/i18nHelpers.js'; +import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js'; import { formatFileSize } from '../utils/formatters.js'; import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js'; @@ -956,11 +957,17 @@ export class DownloadManager { try { this._isDiffusionModel = await this._resolveIsDiffusionModel(); + this._otherSubType = await this._resolveOtherSubType(); let rootsData; if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') { rootsData = await this.apiClient.fetchModelRoots('diffusion_model'); + } else if (this.apiClient.modelType === 'other' && this._otherSubType) { + rootsData = await this.apiClient.fetchModelRoots(this._otherSubType); } else { + // An undecidable other sub_type (null) intentionally lands + // here: fetchModelRoots() lists all other roots so the user + // can pick manually. rootsData = await this.apiClient.fetchModelRoots(); } const modelRoot = document.getElementById('modelRoot'); @@ -968,19 +975,29 @@ export class DownloadManager { `` ).join(''); - const singularType = this._isDiffusionModel - ? 'unet' - : this.apiClient.modelType.replace(/s$/, ''); - const defaultRootKey = `default_${singularType}_root`; - const defaultRoot = state.global.settings[defaultRootKey]; - console.log(`Default root for ${singularType}:`, defaultRoot); + let defaultRoot; + let subtypeDisplay; + if (this.apiClient.modelType === 'other') { + const otherDefaultRoots = state.global.settings.default_other_roots || {}; + defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : ''; + subtypeDisplay = this._otherSubType + ? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType) + : this.apiClient.apiConfig.config.displayName; + } else { + const singularType = this._isDiffusionModel + ? 'unet' + : this.apiClient.modelType.replace(/s$/, ''); + const defaultRootKey = `default_${singularType}_root`; + defaultRoot = state.global.settings[defaultRootKey]; + subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName; + } + console.log('Default root:', defaultRoot); console.log('Available roots:', rootsData.roots); if (defaultRoot && rootsData.roots.includes(defaultRoot)) { console.log(`Setting default root: ${defaultRoot}`); modelRoot.value = defaultRoot; } - const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName; document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: subtypeDisplay }); @@ -1065,6 +1082,50 @@ export class DownloadManager { return localFileTypeCheck; } + /** + * Resolve which other-page sub_type (vae/upscaler/text_encoder/ + * clip_vision/controlnet) this download routes to. The backend owns the + * routing rule (explicit file pick first, model.type next, file.type + * fallback), so the location step sends both the picked file's type + * (selected_file_type) and the version's full file-type list and lets + * the backend apply its priority chain. Returns null when the sub_type + * cannot be decided; the location step then lists all other roots for + * manual selection instead of guessing a folder. + */ + async _resolveOtherSubType() { + // Only other-page downloads route by sub_type; without version + // metadata (e.g. Hugging Face downloads) there is nothing to route on. + if (this.apiClient.modelType !== 'other' + || (!this.selectedFile && !this.currentVersion)) { + return null; + } + + try { + const fileTypes = (this.currentVersion?.files || []).map(f => f.type); + const response = await fetch(DOWNLOAD_ENDPOINTS.routing, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: 'other', + base_model: this.currentVersion?.baseModel || '', + file_types: fileTypes, + ...(this.selectedFile + ? { selected_file_type: this.selectedFile.type } + : {}), + }), + }); + if (!response.ok) { + throw new Error(`routing endpoint returned ${response.status}`); + } + const data = await response.json(); + return data.sub_type || null; + } catch (error) { + console.warn('[download] other routing endpoint unavailable, ' + + 'falling back to manual root selection:', error); + return null; + } + } + loadDefaultPathSetting() { const modelType = this.apiClient.modelType; const storageKey = `use_default_path_${modelType}`; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index ec5f2dc9..5c4ba1f0 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1153,6 +1153,9 @@ export class SettingsManager { // Load default unet root await this.loadUnetRoots(); + // Load default other-model roots (per sub_type) + await this.loadOtherRoots(); + // Load extra folder paths this.loadExtraFolderPaths(); @@ -1658,6 +1661,51 @@ export class SettingsManager { } } + async loadOtherRoots() { + const selects = document.querySelectorAll('select[data-other-root-subtype]'); + if (!selects.length) return; + + try { + // Fetch other-model roots grouped by sub_type + const response = await fetch('/api/lm/other/roots_by_subtype'); + if (!response.ok) { + throw new Error('Failed to fetch other model roots'); + } + + const data = await response.json(); + const groupedRoots = data.roots_by_subtype || {}; + const defaultRoots = state.global.settings.default_other_roots || {}; + + selects.forEach((select) => { + const subType = select.dataset.otherRootSubtype; + const roots = groupedRoots[subType] || []; + if (!roots.length) { + this.showNoRootsPlaceholder(select); + return; + } + + select.innerHTML = ''; + select.disabled = false; + + // Add options for each root + roots.forEach(root => { + const option = document.createElement('option'); + option.value = root; + option.textContent = root; + select.appendChild(option); + }); + + const defaultRoot = defaultRoots[subType] || ''; + select.value = roots.includes(defaultRoot) ? defaultRoot : roots[0]; + }); + + } catch (error) { + console.error('Error loading other model roots:', error); + selects.forEach((select) => this.showNoRootsPlaceholder(select)); + showToast('toast.settings.otherRootsFailed', { message: error.message }, 'error'); + } + } + async loadEmbeddingRoots() { const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot'); if (!defaultEmbeddingRootSelect) return; @@ -2339,6 +2387,27 @@ export class SettingsManager { } } + /** + * Save one sub_type entry of the default_other_roots dict setting + * (read-modify-write: the backend stores the whole mapping). + */ + async saveOtherRootSetting(subType, value) { + try { + const defaultRoots = { ...(state.global.settings.default_other_roots || {}) }; + if (value) { + defaultRoots[subType] = value; + } else { + delete defaultRoots[subType]; + } + + await this.saveSetting('default_other_roots', defaultRoots); + + showToast('toast.settings.settingsUpdated', { setting: `default ${subType} root` }, 'success'); + } catch (error) { + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + } + } + /** * Save the recipes page layout (grid | masonry) and rebuild the scroller. * Shared entry point for the settings modal segmented control and the diff --git a/static/js/state/index.js b/static/js/state/index.js index e20b82d4..f54858f3 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ default_lora_root: '', default_checkpoint_root: '', default_embedding_root: '', + default_other_roots: {}, recipes_path: '', base_model_path_mappings: {}, download_path_templates: {}, @@ -72,6 +73,7 @@ export function createDefaultSettings() { base_model_path_mappings: {}, download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, + default_other_roots: {}, }; } diff --git a/templates/components/controls.html b/templates/components/controls.html index ef83941b..9a7dc1db 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -80,14 +80,12 @@
- {% if page_id != 'other' %}
{% endif %} - {% endif %} {% if page_id == 'recipes' %}
diff --git a/templates/components/modals/settings/library.html b/templates/components/modals/settings/library.html index a1522790..b639d859 100644 --- a/templates/components/modals/settings/library.html +++ b/templates/components/modals/settings/library.html @@ -38,6 +38,34 @@ {{ sm.setting_select('defaultUnetRoot', 'default_unet_root', 'settings.folderSettings.defaultUnetRoot', [], 'settings.folderSettings.defaultUnetRootHelp') }} {{ sm.setting_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }} + + {% set other_root_selects = [ + ('vae', 'defaultOtherRootVae', 'settings.folderSettings.defaultVaeRoot', 'settings.folderSettings.defaultVaeRootHelp'), + ('upscaler', 'defaultOtherRootUpscaler', 'settings.folderSettings.defaultUpscalerRoot', 'settings.folderSettings.defaultUpscalerRootHelp'), + ('text_encoder', 'defaultOtherRootTextEncoder', 'settings.folderSettings.defaultTextEncoderRoot', 'settings.folderSettings.defaultTextEncoderRootHelp'), + ('clip_vision', 'defaultOtherRootClipVision', 'settings.folderSettings.defaultClipVisionRoot', 'settings.folderSettings.defaultClipVisionRootHelp'), + ] %} + {% if 'controlnet' in (settings.get('enabled_other_folders') or []) %} + {% set other_root_selects = other_root_selects + [ + ('controlnet', 'defaultOtherRootControlnet', 'settings.folderSettings.defaultControlnetRoot', 'settings.folderSettings.defaultControlnetRootHelp'), + ] %} + {% endif %} + {% for sub_type, select_id, label_key, help_key in other_root_selects %} +
+
+
+ +
+
+ +
+
+
+ {% endfor %}
diff --git a/tests/frontend/managers/downloadManager.routing.test.js b/tests/frontend/managers/downloadManager.routing.test.js index c4e7ab59..6f7eaf20 100644 --- a/tests/frontend/managers/downloadManager.routing.test.js +++ b/tests/frontend/managers/downloadManager.routing.test.js @@ -56,6 +56,7 @@ vi.mock(SUMMARY_MODULE, () => ({ })); const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE); +const { state } = await import(STATE_MODULE); describe('DownloadManager._resolveIsDiffusionModel', () => { let manager; @@ -144,3 +145,185 @@ describe('DownloadManager._resolveIsDiffusionModel', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('DownloadManager._resolveOtherSubType', () => { + let manager; + let fetchMock; + + beforeEach(() => { + manager = new DownloadManager(); + manager.apiClient = { modelType: 'other' }; + manager.selectedFile = null; + manager.selectedFiles = []; + manager.currentVersion = null; + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockRoutingResponse(data, ok = true) { + fetchMock.mockResolvedValue({ + ok, + status: ok ? 200 : 500, + json: async () => data, + }); + } + + it('returns the backend sub_type for other downloads', async () => { + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' }); + + expect(await manager._resolveOtherSubType()).toBe('vae'); + + expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: 'other', + base_model: 'Flux.1 D', + file_types: ['VAE'], + }), + }); + }); + + it('sends selected_file_type plus all version file types when a file is selected', async () => { + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] }; + manager.selectedFile = { type: 'VAE' }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' }); + + expect(await manager._resolveOtherSubType()).toBe('vae'); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.selected_file_type).toBe('VAE'); + expect(body.file_types).toEqual(['Model', 'VAE']); + }); + + it('omits selected_file_type when no file is selected', async () => { + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' }); + + await manager._resolveOtherSubType(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect('selected_file_type' in body).toBe(false); + expect(body.file_types).toEqual(['Model', 'VAE']); + }); + + it('returns null when the backend cannot decide a sub_type', async () => { + manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: null }); + + expect(await manager._resolveOtherSubType()).toBeNull(); + }); + + it('returns null when the endpoint fails', async () => { + manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] }; + fetchMock.mockRejectedValue(new Error('network down')); + + expect(await manager._resolveOtherSubType()).toBeNull(); + }); + + it('returns null on a non-ok response', async () => { + manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] }; + mockRoutingResponse({}, false); + + expect(await manager._resolveOtherSubType()).toBeNull(); + }); + + it('never calls the endpoint for non-other pages', async () => { + manager.apiClient = { modelType: 'loras' }; + manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] }; + + expect(await manager._resolveOtherSubType()).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never calls the endpoint without version metadata (e.g. Hugging Face)', async () => { + expect(await manager._resolveOtherSubType()).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('DownloadManager.proceedToLocationContent (other page)', () => { + let manager; + + beforeEach(() => { + document.body.innerHTML = ` + + + + `; + state.global.settings = {}; + + manager = new DownloadManager(); + manager.apiClient = { + modelType: 'other', + apiConfig: { config: { displayName: 'Other Model' } }, + fetchModelRoots: vi.fn(), + }; + manager.selectedFile = null; + manager.selectedFiles = []; + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] }; + manager.initializeFolderTree = vi.fn().mockResolvedValue(); + manager.folderTreeManager = { init: vi.fn() }; + manager.loadDefaultPathSetting = vi.fn(); + manager.updateTargetPath = vi.fn(); + vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false); + }); + + it('fetches sub_type roots and preselects the configured default root', async () => { + vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue('vae'); + manager.apiClient.fetchModelRoots.mockResolvedValue({ + success: true, + roots: ['/models/vae-a', '/models/vae-b'], + }); + state.global.settings.default_other_roots = { vae: '/models/vae-b' }; + + await manager.proceedToLocationContent(); + + expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('vae'); + const modelRoot = document.getElementById('modelRoot'); + expect(Array.from(modelRoot.options).map(o => o.value)).toEqual([ + '/models/vae-a', + '/models/vae-b', + ]); + expect(modelRoot.value).toBe('/models/vae-b'); + }); + + it('lists all other roots for manual selection when the sub_type is undecidable', async () => { + vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue(null); + manager.apiClient.fetchModelRoots.mockResolvedValue({ + success: true, + roots: ['/models/vae', '/models/upscale'], + }); + state.global.settings.default_other_roots = { vae: '/models/upscale' }; + + await manager.proceedToLocationContent(); + + // No argument: the merged /api/lm/other/roots list + expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith(); + const modelRoot = document.getElementById('modelRoot'); + expect(Array.from(modelRoot.options).map(o => o.value)).toEqual([ + '/models/vae', + '/models/upscale', + ]); + // Without a resolved sub_type no default_other_roots entry applies, + // so the first option stays selected even though a vae default exists. + expect(modelRoot.value).toBe('/models/vae'); + }); + + it('leaves the first root selected when no default is configured for the sub_type', async () => { + vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue('upscaler'); + manager.apiClient.fetchModelRoots.mockResolvedValue({ + success: true, + roots: ['/models/upscale'], + }); + state.global.settings.default_other_roots = { vae: '/models/vae-a' }; + + await manager.proceedToLocationContent(); + + expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('upscaler'); + expect(document.getElementById('modelRoot').value).toBe('/models/upscale'); + }); +}); diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js index 115db1eb..88e59439 100644 --- a/tests/frontend/managers/settingsManager.library.test.js +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -24,6 +24,7 @@ vi.mock('../../../static/js/state/index.js', () => { }, createDefaultSettings: () => ({ language: 'en', + default_other_roots: {}, }), }; }); @@ -502,6 +503,182 @@ describe('SettingsManager library controls', () => { }); }); +describe('SettingsManager other-model root selects', () => { + const appendOtherRootSelects = (...subTypes) => { + const selects = {}; + subTypes.forEach((subType) => { + const select = document.createElement('select'); + select.dataset.otherRootSubtype = subType; + document.body.appendChild(select); + selects[subType] = select; + }); + return selects; + }; + + describe('loadOtherRoots', () => { + it('populates each sub_type select from the grouped roots and preselects defaults', async () => { + const manager = createManager(); + const selects = appendOtherRootSelects('vae', 'upscaler'); + selects.vae.disabled = true; + + state.global.settings = { + default_other_roots: { vae: '/models/vae-b' }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + roots_by_subtype: { + vae: ['/models/vae-a', '/models/vae-b'], + upscaler: ['/models/upscale'], + }, + }), + }); + + await manager.loadOtherRoots(); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/other/roots_by_subtype'); + expect(Array.from(selects.vae.options).map(o => o.value)).toEqual([ + '/models/vae-a', + '/models/vae-b', + ]); + expect(selects.vae.value).toBe('/models/vae-b'); + expect(selects.vae.disabled).toBe(false); + expect(Array.from(selects.upscaler.options).map(o => o.value)).toEqual([ + '/models/upscale', + ]); + // No configured default: first root wins + expect(selects.upscaler.value).toBe('/models/upscale'); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('shows a placeholder on selects whose sub_type has no roots', async () => { + const manager = createManager(); + const selects = appendOtherRootSelects('vae', 'controlnet'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + roots_by_subtype: { vae: ['/models/vae-a'] }, + }), + }); + + await manager.loadOtherRoots(); + + expect(selects.controlnet.options).toHaveLength(1); + expect(selects.controlnet.options[0].value).toBe(''); + expect(selects.controlnet.options[0].textContent).toBe('No Default'); + expect(selects.controlnet.disabled).toBe(true); + expect(selects.vae.disabled).toBe(false); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('shows an error toast and placeholders when the request fails', async () => { + const manager = createManager(); + const selects = appendOtherRootSelects('vae', 'upscaler'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + await manager.loadOtherRoots(); + + expect(selects.vae.disabled).toBe(true); + expect(selects.upscaler.disabled).toBe(true); + expect(showToast).toHaveBeenCalledWith( + 'toast.settings.otherRootsFailed', + expect.objectContaining({ message: expect.any(String) }), + 'error', + ); + }); + + it('does not call the API when no sub_type selects exist', async () => { + const manager = createManager(); + global.fetch = vi.fn(); + + await manager.loadOtherRoots(); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('saveOtherRootSetting', () => { + it('read-modify-writes the default_other_roots dict and posts it whole', async () => { + const manager = createManager(); + state.global.settings = { + default_other_roots: { vae: '/models/vae-a' }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await manager.saveOtherRootSetting('upscaler', '/models/upscale'); + + expect(state.global.settings.default_other_roots).toEqual({ + vae: '/models/vae-a', + upscaler: '/models/upscale', + }); + expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + default_other_roots: { + vae: '/models/vae-a', + upscaler: '/models/upscale', + }, + }), + }); + expect(showToast).toHaveBeenCalledWith( + 'toast.settings.settingsUpdated', + expect.objectContaining({ setting: expect.any(String) }), + 'success', + ); + }); + + it('removes the sub_type key when the value is empty', async () => { + const manager = createManager(); + state.global.settings = { + default_other_roots: { vae: '/models/vae-a' }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await manager.saveOtherRootSetting('vae', ''); + + expect(state.global.settings.default_other_roots).toEqual({}); + expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ + default_other_roots: {}, + }); + }); + + it('shows an error toast when the backend save fails', async () => { + const manager = createManager(); + state.global.settings = { default_other_roots: {} }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + await manager.saveOtherRootSetting('vae', '/models/vae-a'); + + expect(showToast).toHaveBeenCalledWith( + 'toast.settings.settingSaveFailed', + expect.objectContaining({ message: expect.any(String) }), + 'error', + ); + }); + }); +}); + describe('SettingsManager recipes layout switch', () => { it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => { const manager = createManager();