feat(frontend): opt-in Other Models toggles, hidden nav and announcement

- The Other nav entry is hidden while the feature is off
  (nav-item--hidden, toggled client-side after enabling) and now uses the
  fa-shapes icon.
- Shared utils/otherModels.js helpers (enable through the settings API,
  open the settings Library section) are reused by the disabled page, the
  announcement banner and the download modal.
- BannerService registers a one-time dismissible "other-models-announcement"
  banner while the feature is off; SettingsManager drops the banner and
  updates the nav when the master switch flips.
- A disabled download routing answer now surfaces a showActionToast with an
  "Enable Other Models" action.
- Settings UI: master toggle + five sub_type checkboxes whose default-root
  selects are disabled when unchecked; i18n keys added to en.json and synced
  (other locales keep TODO placeholders).
This commit is contained in:
Will Miao
2026-09-13 07:59:28 +08:00
parent 28fbb86dce
commit 69a62d739c
25 changed files with 1006 additions and 19 deletions
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "Rezepte-Speicherpfad", "recipesPath": "Rezepte-Speicherpfad",
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.", "recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Embedding-Modelle" "title": "Embedding-Modelle"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
@@ -2615,6 +2633,12 @@
"rebuilding": "Cache wird neu aufgebaut...", "rebuilding": "Cache wird neu aufgebaut...",
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}", "rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
"retry": "Wiederholen" "retry": "Wiederholen"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "ControlNet Root", "defaultControlnetRoot": "ControlNet Root",
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "Other Models Management",
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "Managed Types",
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Recipes Storage Path", "recipesPath": "Recipes Storage Path",
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.", "recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Embedding Models" "title": "Embedding Models"
}, },
"other": { "other": {
"title": "Other Models" "title": "Other Models",
"disabled": {
"title": "Other Models management is off",
"description": "Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "Enable Other Models",
"hint": "You can change the managed model types later in Settings > Library.",
"enableFailed": "Failed to enable Other Models",
"downloadBlocked": "Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
@@ -2615,6 +2633,12 @@
"rebuilding": "Rebuilding cache...", "rebuilding": "Rebuilding cache...",
"rebuildFailed": "Failed to rebuild cache: {error}", "rebuildFailed": "Failed to rebuild cache: {error}",
"retry": "Retry" "retry": "Retry"
},
"otherModels": {
"title": "Other Models Management is available",
"content": "Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "Enable Other Models",
"openSettings": "Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "Ruta de almacenamiento de recetas", "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.", "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", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Modelos embedding" "title": "Modelos embedding"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
@@ -2615,6 +2633,12 @@
"rebuilding": "Reconstruyendo caché...", "rebuilding": "Reconstruyendo caché...",
"rebuildFailed": "Error al reconstruir la caché: {error}", "rebuildFailed": "Error al reconstruir la caché: {error}",
"retry": "Reintentar" "retry": "Reintentar"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "Chemin de stockage des Recipes", "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.", "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", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Modèles Embedding" "title": "Modèles Embedding"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
@@ -2615,6 +2633,12 @@
"rebuilding": "Reconstruction du cache...", "rebuilding": "Reconstruction du cache...",
"rebuildFailed": "Échec de la reconstruction du cache : {error}", "rebuildFailed": "Échec de la reconstruction du cache : {error}",
"retry": "Réessayer" "retry": "Réessayer"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "נתיב אחסון מתכונים", "recipesPath": "נתיב אחסון מתכונים",
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.", "recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "מודלי Embedding" "title": "מודלי Embedding"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
@@ -2615,6 +2633,12 @@
"rebuilding": "בונה מחדש את המטמון...", "rebuilding": "בונה מחדש את המטמון...",
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}", "rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
"retry": "נסה שוב" "retry": "נסה שוב"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "レシピ保存先", "recipesPath": "レシピ保存先",
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。", "recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Embeddingモデル" "title": "Embeddingモデル"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
@@ -2615,6 +2633,12 @@
"rebuilding": "キャッシュを再構築中...", "rebuilding": "キャッシュを再構築中...",
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}", "rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
"retry": "再試行" "retry": "再試行"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "레시피 저장 경로", "recipesPath": "레시피 저장 경로",
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.", "recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Embedding 모델" "title": "Embedding 모델"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
@@ -2615,6 +2633,12 @@
"rebuilding": "캐시 재구축 중...", "rebuilding": "캐시 재구축 중...",
"rebuildFailed": "캐시 재구축 실패: {error}", "rebuildFailed": "캐시 재구축 실패: {error}",
"retry": "다시 시도" "retry": "다시 시도"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "Путь хранения рецептов", "recipesPath": "Путь хранения рецептов",
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.", "recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Модели Embedding" "title": "Модели Embedding"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
@@ -2615,6 +2633,12 @@
"rebuilding": "Перестроение кэша...", "rebuilding": "Перестроение кэша...",
"rebuildFailed": "Не удалось перестроить кэш: {error}", "rebuildFailed": "Не удалось перестроить кэш: {error}",
"retry": "Повторить" "retry": "Повторить"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "配方存储路径", "recipesPath": "配方存储路径",
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。", "recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Embedding 模型" "title": "Embedding 模型"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
@@ -2615,6 +2633,12 @@
"rebuilding": "正在重建缓存...", "rebuilding": "正在重建缓存...",
"rebuildFailed": "重建缓存失败:{error}", "rebuildFailed": "重建缓存失败:{error}",
"retry": "重试" "retry": "重试"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+25 -1
View File
@@ -545,6 +545,15 @@
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves", "defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root", "defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves", "defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "[TODO: Translate] Other Models Management",
"enableOtherModelsHelp": "[TODO: Translate] When off, VAE / upscaler / text encoder / CLIP folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "[TODO: Translate] Managed Types",
"otherSubTypesHelp": "[TODO: Translate] Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "[TODO: Translate] VAE",
"subTypeUpscaler": "[TODO: Translate] Upscaler",
"subTypeTextEncoder": "[TODO: Translate] Text Encoder",
"subTypeClipVision": "[TODO: Translate] CLIP Vision",
"subTypeControlnet": "[TODO: Translate] ControlNet",
"recipesPath": "配方儲存路徑", "recipesPath": "配方儲存路徑",
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。", "recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
"recipesPathPlaceholder": "/path/to/recipes", "recipesPathPlaceholder": "/path/to/recipes",
@@ -1214,7 +1223,16 @@
"title": "Embedding 模型" "title": "Embedding 模型"
}, },
"other": { "other": {
"title": "[TODO: Translate] Other Models" "title": "[TODO: Translate] Other Models",
"disabled": {
"title": "[TODO: Translate] Other Models management is off",
"description": "[TODO: Translate] Enable it to scan and manage VAE, upscaler, text encoder and CLIP vision files, and to download them from CivitAI.",
"enableButton": "[TODO: Translate] Enable Other Models",
"hint": "[TODO: Translate] You can change the managed model types later in Settings > Library.",
"enableFailed": "[TODO: Translate] Failed to enable Other Models",
"downloadBlocked": "[TODO: Translate] Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "[TODO: Translate] Enable Other Models"
}
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
@@ -2615,6 +2633,12 @@
"rebuilding": "重建快取中...", "rebuilding": "重建快取中...",
"rebuildFailed": "重建快取失敗:{error}", "rebuildFailed": "重建快取失敗:{error}",
"retry": "重試" "retry": "重試"
},
"otherModels": {
"title": "[TODO: Translate] Other Models Management is available",
"content": "[TODO: Translate] Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.",
"enable": "[TODO: Translate] Enable Other Models",
"openSettings": "[TODO: Translate] Open Settings"
} }
} }
} }
+6
View File
@@ -83,6 +83,12 @@
white-space: nowrap; white-space: nowrap;
} }
/* Opt-in pages (e.g. Other Models) hide their nav entry until enabled.
A class is used instead of [hidden] because .nav-item sets display: flex. */
.nav-item--hidden {
display: none;
}
.nav-item:hover, .nav-item:hover,
.nav-item:focus-visible { .nav-item:focus-visible {
background-color: var(--lora-surface-hover, oklch(95% 0.02 256)); background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
@@ -1744,3 +1744,39 @@ input:checked + .toggle-slider:before {
font-style: italic; font-style: italic;
user-select: none; user-select: none;
} }
/* Other Models opt-in: sub_type checkbox row */
.other-subtype-checkboxes {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px 14px;
}
.other-subtype-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
color: var(--text-color);
cursor: pointer;
white-space: nowrap;
}
.other-subtype-checkbox input[type="checkbox"] {
cursor: pointer;
}
.other-subtype-toggles.is-disabled {
opacity: 0.5;
}
.other-subtype-toggles.is-disabled .other-subtype-checkbox {
cursor: default;
}
/* Disabled default-root selects for switched-off sub_types / feature */
.select-control select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+89
View File
@@ -6,9 +6,11 @@ import {
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { enableOtherModels, openOtherModelsSettings } from '../utils/otherModels.js';
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support'; const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning'; const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at'; const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version'; const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
@@ -80,6 +82,7 @@ class BannerService {
}); });
this.prepareCommunitySupportBanner(); this.prepareCommunitySupportBanner();
this.prepareOtherModelsBanner();
await this.showActiveBanners(); await this.showActiveBanners();
this.initialized = true; this.initialized = true;
@@ -541,6 +544,92 @@ class BannerService {
this.updateContainerVisibility(); this.updateContainerVisibility();
} }
/**
* Announce the opt-in Other Models management to users who have not turned
* it on yet. Dismissal is persisted through the shared dismissed_banners
* setting, so users who are not interested are not nagged again.
*/
prepareOtherModelsBanner() {
if (state.global.settings.enable_other_models) {
return;
}
if (this.isBannerDismissed(OTHER_MODELS_BANNER_ID)) {
return;
}
this.registerBanner(OTHER_MODELS_BANNER_ID, {
id: OTHER_MODELS_BANNER_ID,
title: translate(
'banners.otherModels.title',
{},
'Other Models Management is available'
),
content: translate(
'banners.otherModels.content',
{},
'Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.'
),
actions: [
{
text: translate(
'banners.otherModels.enable',
{},
'Enable Other Models'
),
icon: 'fas fa-shapes',
type: 'primary',
action: 'enable-other-models'
},
{
text: translate(
'banners.otherModels.openSettings',
{},
'Open Settings'
),
icon: 'fas fa-cog',
type: 'secondary',
action: 'open-other-models-settings'
}
],
dismissible: true,
priority: 0,
onRegister: (bannerElement) => {
const enableButton = bannerElement.querySelector(
'.banner-action[data-action="enable-other-models"]'
);
if (enableButton) {
enableButton.addEventListener('click', (event) => {
event.preventDefault();
enableOtherModels().catch((error) => {
console.error('Failed to enable Other Models:', error);
});
});
}
const settingsButton = bannerElement.querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
if (settingsButton) {
settingsButton.addEventListener('click', (event) => {
event.preventDefault();
openOtherModelsSettings();
});
}
}
});
this.updateContainerVisibility();
}
/**
* Drop the Other Models announcement once the feature is enabled.
* Dismissal is deliberately NOT persisted, so the announcement can come
* back if the user switches the feature off again.
*/
removeOtherModelsAnnouncement() {
this.removeBannerElement(OTHER_MODELS_BANNER_ID);
}
initializeCommunitySupportState() { initializeCommunitySupportState() {
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null); const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
+12 -1
View File
@@ -1,5 +1,5 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js'; import { showToast, showActionToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js'; import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -12,6 +12,7 @@ import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js'; import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js'; import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js'; import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
import { openOtherModelsSettings } from '../utils/otherModels.js';
export class DownloadManager { export class DownloadManager {
constructor() { constructor() {
@@ -1118,6 +1119,16 @@ export class DownloadManager {
throw new Error(`routing endpoint returned ${response.status}`); throw new Error(`routing endpoint returned ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
if (data.disabled) {
// The matching sub_type (or the whole Other Models feature) is
// switched off: auto-routing is refused, so offer the settings
// shortcut while the user's intent is clear.
showActionToast('other.disabled.downloadBlocked', {}, 'warning', {
actionText: translate('other.disabled.enableAction', {}, 'Enable Other Models'),
onAction: () => openOtherModelsSettings(),
});
return null;
}
return data.sub_type || null; return data.sub_type || null;
} catch (error) { } catch (error) {
console.warn('[download] other routing endpoint unavailable, ' console.warn('[download] other routing endpoint unavailable, '
+87
View File
@@ -1155,6 +1155,7 @@ export class SettingsManager {
// Load default other-model roots (per sub_type) // Load default other-model roots (per sub_type)
await this.loadOtherRoots(); await this.loadOtherRoots();
this.updateOtherModelsControls();
// Load extra folder paths // Load extra folder paths
this.loadExtraFolderPaths(); this.loadExtraFolderPaths();
@@ -2304,6 +2305,16 @@ export class SettingsManager {
await this.updateBackupStatus(); await this.updateBackupStatus();
} }
if (settingKey === 'enable_other_models') {
// Roots only exist while the feature is on, so re-fetch them
// after the backend rebuilt the other-model root set.
this.updateOtherModelsControls();
await this.loadOtherRoots();
this.updateOtherModelsControls();
this.updateOtherModelsNavVisibility(value);
this.removeOtherModelsAnnouncement(value);
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
// Apply frontend settings immediately // Apply frontend settings immediately
@@ -2408,6 +2419,82 @@ export class SettingsManager {
} }
} }
/**
* Reflect the opt-in Other Models state in the settings UI: the master
* toggle gates every sub_type checkbox, and a switched-off sub_type has
* its default-root select disabled. Never force-enables a select (the
* no-roots placeholder owns that state).
*/
updateOtherModelsControls() {
const enableOtherModels = !!state.global.settings.enable_other_models;
const enabledSubTypes = new Set(
state.global.settings.enabled_other_sub_types
|| ['vae', 'upscaler', 'text_encoder', 'clip_vision']
);
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
input.checked = enabledSubTypes.has(input.value);
input.disabled = !enableOtherModels;
});
const container = document.getElementById('otherSubTypeToggles');
if (container) {
container.classList.toggle('is-disabled', !enableOtherModels);
}
document.querySelectorAll('select[data-other-root-subtype]').forEach((select) => {
const subType = select.dataset.otherRootSubtype;
if (!enableOtherModels || !enabledSubTypes.has(subType)) {
select.disabled = true;
}
});
}
/**
* Persist the whole enabled_other_sub_types list (the backend stores an
* allow-list) and refresh the per-sub_type default-root selects.
*/
async saveEnabledOtherSubTypes() {
const values = Array.from(
document.querySelectorAll('[data-other-subtype-toggle]')
)
.filter((input) => input.checked)
.map((input) => input.value);
try {
await this.saveSetting('enabled_other_sub_types', values);
this.updateOtherModelsControls();
await this.loadOtherRoots();
this.updateOtherModelsControls();
showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
/**
* Show or hide the Other Models nav entry. The nav is server-rendered, so
* toggling the class here keeps it in sync when the switch is flipped from
* the settings modal (no reload needed).
*/
updateOtherModelsNavVisibility(enabled) {
const navItem = document.getElementById('otherNavItem');
if (navItem) {
navItem.classList.toggle('nav-item--hidden', !enabled);
}
}
/**
* Drop the Other Models announcement banner once the feature is on.
*/
removeOtherModelsAnnouncement(enabled) {
if (!enabled) {
return;
}
bannerService.removeOtherModelsAnnouncement();
}
/** /**
* Save the recipes page layout (grid | masonry) and rebuild the scroller. * Save the recipes page layout (grid | masonry) and rebuild the scroller.
* Shared entry point for the settings modal segmented control and the * Shared entry point for the settings modal segmented control and the
+36
View File
@@ -0,0 +1,36 @@
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
import { enableOtherModels } from './utils/otherModels.js';
/**
* Other Models is an opt-in feature. While it is disabled this page renders an
* empty state whose button turns the feature on; the backend then rebuilds the
* other-model roots and starts scanning, so a reload lands on the real page.
*/
async function handleEnableClick() {
const button = document.getElementById('enableOtherModelsBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
await enableOtherModels();
} catch (error) {
button.disabled = false;
showToast('other.disabled.enableFailed', { message: error.message }, 'error');
}
}
async function initializeOtherDisabledPage() {
// appCore.initialize() wires the shared header (theme, settings modal,
// language) so this page is not a dead end.
await appCore.initialize();
const button = document.getElementById('enableOtherModelsBtn');
if (button) {
button.addEventListener('click', handleEnableClick);
}
}
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage };
+3
View File
@@ -25,6 +25,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
default_checkpoint_root: '', default_checkpoint_root: '',
default_embedding_root: '', default_embedding_root: '',
default_other_roots: {}, default_other_roots: {},
enable_other_models: false,
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder', 'clip_vision'],
recipes_path: '', recipes_path: '',
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: {}, download_path_templates: {},
@@ -74,6 +76,7 @@ export function createDefaultSettings() {
download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
default_other_roots: {}, default_other_roots: {},
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder', 'clip_vision'],
}; };
} }
+52
View File
@@ -0,0 +1,52 @@
/**
* Shared helpers for the opt-in Other Models feature.
*
* Used by the disabled page, the announcement banner and the download modal so
* that enabling the feature always goes through the same settings API call and
* lands on the same settings section.
*/
/**
* Turn on Other Models management and reload so the server-rendered nav and
* the scanner state pick up the change.
*/
export async function enableOtherModels() {
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable_other_models: true }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
window.location.reload();
}
/**
* Open the settings modal on the Library section and scroll the Other Models
* toggle into view. Mirrors DoctorManager's open-settings-syntax-format flow.
*/
export function openOtherModelsSettings() {
const modalManager = window.modalManager;
if (modalManager && typeof modalManager.showModal === 'function') {
modalManager.showModal('settingsModal');
}
window.setTimeout(() => {
document.querySelectorAll('.settings-section').forEach((section) => {
section.classList.remove('active');
});
document.getElementById('section-library')?.classList.add('active');
document.querySelectorAll('.settings-nav-item').forEach((item) => {
item.classList.remove('active');
});
document.querySelector('.settings-nav-item[data-section="library"]')?.classList.add('active');
document.getElementById('enableOtherModels')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 100);
}
+2 -2
View File
@@ -38,9 +38,9 @@
id="embeddingsNavItem"> id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span> <i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a> </a>
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}" <a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
id="otherNavItem"> id="otherNavItem">
<i class="fas fa-cubes"></i> <span>{{ t('header.navigation.other') }}</span> <i class="fas fa-shapes"></i> <span>{{ t('header.navigation.other') }}</span>
</a> </a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}" <a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem"> id="statisticsNavItem">
@@ -39,17 +39,44 @@
{{ sm.setting_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }} {{ sm.setting_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }}
{{ sm.setting_toggle('enableOtherModels', 'enable_other_models', 'settings.folderSettings.enableOtherModels', 'settings.folderSettings.enableOtherModelsHelp') }}
{% set enabled_other_sub_types = settings.get('enabled_other_sub_types') or ['vae', 'upscaler', 'text_encoder', 'clip_vision'] %}
<div class="setting-item other-subtype-toggles" id="otherSubTypeToggles">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.folderSettings.otherSubTypes') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.otherSubTypesHelp') }}"></i>
</label>
</div>
<div class="setting-control other-subtype-checkboxes">
{% for sub_type, label_key in [
('vae', 'settings.folderSettings.subTypeVae'),
('upscaler', 'settings.folderSettings.subTypeUpscaler'),
('text_encoder', 'settings.folderSettings.subTypeTextEncoder'),
('clip_vision', 'settings.folderSettings.subTypeClipVision'),
('controlnet', 'settings.folderSettings.subTypeControlnet'),
] %}
<label class="other-subtype-checkbox">
<input type="checkbox" value="{{ sub_type }}"
data-other-subtype-toggle="{{ sub_type }}"
{% if sub_type in enabled_other_sub_types %}checked{% endif %}
onchange="settingsManager.saveEnabledOtherSubTypes()">
<span>{{ t(label_key) }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
{% set other_root_selects = [ {% set other_root_selects = [
('vae', 'defaultOtherRootVae', 'settings.folderSettings.defaultVaeRoot', 'settings.folderSettings.defaultVaeRootHelp'), ('vae', 'defaultOtherRootVae', 'settings.folderSettings.defaultVaeRoot', 'settings.folderSettings.defaultVaeRootHelp'),
('upscaler', 'defaultOtherRootUpscaler', 'settings.folderSettings.defaultUpscalerRoot', 'settings.folderSettings.defaultUpscalerRootHelp'), ('upscaler', 'defaultOtherRootUpscaler', 'settings.folderSettings.defaultUpscalerRoot', 'settings.folderSettings.defaultUpscalerRootHelp'),
('text_encoder', 'defaultOtherRootTextEncoder', 'settings.folderSettings.defaultTextEncoderRoot', 'settings.folderSettings.defaultTextEncoderRootHelp'), ('text_encoder', 'defaultOtherRootTextEncoder', 'settings.folderSettings.defaultTextEncoderRoot', 'settings.folderSettings.defaultTextEncoderRootHelp'),
('clip_vision', 'defaultOtherRootClipVision', 'settings.folderSettings.defaultClipVisionRoot', 'settings.folderSettings.defaultClipVisionRootHelp'), ('clip_vision', 'defaultOtherRootClipVision', 'settings.folderSettings.defaultClipVisionRoot', 'settings.folderSettings.defaultClipVisionRootHelp'),
('controlnet', 'defaultOtherRootControlnet', 'settings.folderSettings.defaultControlnetRoot', 'settings.folderSettings.defaultControlnetRootHelp'),
] %} ] %}
{% 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 %} {% for sub_type, select_id, label_key, help_key in other_root_selects %}
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
@@ -60,7 +87,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="{{ select_id }}" data-other-root-subtype="{{ sub_type }}" onchange="settingsManager.saveOtherRootSetting('{{ sub_type }}', this.value)"> <select id="{{ select_id }}" data-other-root-subtype="{{ sub_type }}" {% if sub_type not in enabled_other_sub_types %}disabled{% endif %} onchange="settingsManager.saveOtherRootSetting('{{ sub_type }}', this.value)">
</select> </select>
</div> </div>
</div> </div>
+63
View File
@@ -7,6 +7,53 @@
{% block init_message %}{{ t('initialization.other.message') }}{% endblock %} {% block init_message %}{{ t('initialization.other.message') }}{% endblock %}
{% block init_check_url %}/api/other/list?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/other/list?page=1&page_size=1{% endblock %}
{% block page_css %}
<style>
.other-disabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 64px 24px;
color: var(--text-color, #e0e0e0);
}
.other-disabled > i {
font-size: 48px;
opacity: 0.6;
}
.other-disabled h2 {
margin: 0;
font-size: 20px;
}
.other-disabled p {
margin: 0;
max-width: 520px;
line-height: 1.6;
opacity: 0.8;
}
.other-disabled .other-disabled-hint {
font-size: 13px;
opacity: 0.6;
}
.other-disabled button {
margin-top: 8px;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #fff;
background: var(--primary-color, #4a7dff);
}
.other-disabled button:disabled {
opacity: 0.6;
cursor: default;
}
</style>
{% endblock %}
{% block additional_components %} {% block additional_components %}
<div id="otherContextMenu" class="context-menu" style="display: none;"> <div id="otherContextMenu" class="context-menu" style="display: none;">
@@ -52,6 +99,17 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if other_disabled %}
<div class="other-disabled">
<i class="fas fa-shapes"></i>
<h2>{{ t('other.disabled.title') }}</h2>
<p>{{ t('other.disabled.description') }}</p>
<button id="enableOtherModelsBtn" type="button">
<i class="fas fa-toggle-on"></i> {{ t('other.disabled.enableButton') }}
</button>
<p class="other-disabled-hint">{{ t('other.disabled.hint') }}</p>
</div>
{% else %}
<div class="sticky-topbar"> <div class="sticky-topbar">
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %} {% include 'components/breadcrumb.html' %}
@@ -63,6 +121,7 @@
<div class="card-grid" id="modelGrid"> <div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here --> <!-- Cards will be dynamically inserted here -->
</div> </div>
{% endif %}
{% endblock %} {% endblock %}
{% block overlay %} {% block overlay %}
@@ -70,5 +129,9 @@
{% endblock %} {% endblock %}
{% block main_script %} {% block main_script %}
{% if other_disabled %}
<script type="module" src="/loras_static/js/other_disabled.js?v={{ version }}"></script>
{% else %}
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script> <script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
{% endif %}
{% endblock %} {% endblock %}
@@ -27,6 +27,14 @@ vi.mock('../../../static/js/state/index.js', () => ({
} }
})); }));
// Mock the shared Other Models helpers (exercised by their own tests)
vi.mock('../../../static/js/utils/otherModels.js', () => ({
enableOtherModels: vi.fn().mockResolvedValue(),
openOtherModelsSettings: vi.fn(),
}));
import { enableOtherModels, openOtherModelsSettings } from '../../../static/js/utils/otherModels.js';
describe('BannerService', () => { describe('BannerService', () => {
beforeEach(() => { beforeEach(() => {
// Clear all mocks // Clear all mocks
@@ -186,6 +194,91 @@ describe('BannerService', () => {
}); });
}); });
describe('Other Models announcement', () => {
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const prepareBanner = (dismissed = []) => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return dismissed;
}
return defaultValue;
});
bannerService.container = document.getElementById('banner-container');
bannerService.initialized = true;
bannerService.prepareOtherModelsBanner();
};
const bannerElement = () =>
document.querySelector(`[data-banner-id="${OTHER_MODELS_BANNER_ID}"]`);
beforeEach(() => {
state.global.settings.enable_other_models = false;
});
it('announces the feature while it is switched off', () => {
prepareBanner();
const element = bannerElement();
expect(element).not.toBeNull();
expect(element.querySelector('.banner-title').textContent)
.toContain('Other Models Management is available');
});
it('stays silent once the feature is enabled', () => {
state.global.settings.enable_other_models = true;
prepareBanner();
expect(bannerElement()).toBeNull();
});
it('stays silent when it was dismissed before', () => {
prepareBanner([OTHER_MODELS_BANNER_ID]);
expect(bannerElement()).toBeNull();
});
it('enables the feature from the primary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="enable-other-models"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(enableOtherModels).toHaveBeenCalledTimes(1);
});
it('opens the settings section from the secondary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('can drop the announcement without dismissing it', () => {
prepareBanner();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(true);
bannerService.removeOtherModelsAnnouncement();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(false);
expect(storageHelpers.setStorageItem).not.toHaveBeenCalledWith(
'dismissed_banners',
expect.arrayContaining([OTHER_MODELS_BANNER_ID])
);
});
});
describe('Banner Dismissal', () => { describe('Banner Dismissal', () => {
it('should add banner to dismissed_banners array when dismissed', () => { it('should add banner to dismissed_banners array when dismissed', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
@@ -11,6 +11,7 @@ const {
FOLDER_TREE_MANAGER_MODULE, FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE, I18N_HELPERS_MODULE,
SUMMARY_MODULE, SUMMARY_MODULE,
OTHER_MODELS_MODULE,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname, DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
@@ -22,6 +23,7 @@ const {
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname, FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname, SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
OTHER_MODELS_MODULE: new URL('../../../static/js/utils/otherModels.js', import.meta.url).pathname,
})); }));
vi.mock(MODAL_MANAGER_MODULE, () => ({ vi.mock(MODAL_MANAGER_MODULE, () => ({
@@ -29,6 +31,7 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
})); }));
vi.mock(UI_HELPERS_MODULE, () => ({ vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(), showToast: vi.fn(),
showActionToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(), setupAutoNewlineOnPaste: vi.fn(),
})); }));
vi.mock(STATE_MODULE, () => ({ vi.mock(STATE_MODULE, () => ({
@@ -54,9 +57,15 @@ vi.mock(I18N_HELPERS_MODULE, () => ({
vi.mock(SUMMARY_MODULE, () => ({ vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: vi.fn(), showDownloadBatchSummary: vi.fn(),
})); }));
vi.mock(OTHER_MODELS_MODULE, () => ({
enableOtherModels: vi.fn(),
openOtherModelsSettings: vi.fn(),
}));
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE); const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
const { state } = await import(STATE_MODULE); const { state } = await import(STATE_MODULE);
const { showActionToast } = await import(UI_HELPERS_MODULE);
const { openOtherModelsSettings } = await import(OTHER_MODELS_MODULE);
describe('DownloadManager._resolveIsDiffusionModel', () => { describe('DownloadManager._resolveIsDiffusionModel', () => {
let manager; let manager;
@@ -217,6 +226,35 @@ describe('DownloadManager._resolveOtherSubType', () => {
expect(await manager._resolveOtherSubType()).toBeNull(); expect(await manager._resolveOtherSubType()).toBeNull();
}); });
it('offers the settings shortcut when the feature is disabled for this type', async () => {
showActionToast.mockClear();
openOtherModelsSettings.mockClear();
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
mockRoutingResponse({
success: true,
root_kind: 'other',
sub_type: null,
disabled: true,
reason: 'other_sub_type_disabled',
});
expect(await manager._resolveOtherSubType()).toBeNull();
expect(showActionToast).toHaveBeenCalledWith(
'other.disabled.downloadBlocked',
{},
'warning',
expect.objectContaining({
actionText: expect.any(String),
onAction: expect.any(Function),
}),
);
showActionToast.mock.calls.at(-1)[3].onAction();
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('returns null when the endpoint fails', async () => { it('returns null when the endpoint fails', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] }; manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
fetchMock.mockRejectedValue(new Error('network down')); fetchMock.mockRejectedValue(new Error('network down'));
@@ -62,6 +62,7 @@ vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
})); }));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js'; import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { bannerService } from '../../../static/js/managers/BannerService.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js'; import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { state } from '../../../static/js/state/index.js'; import { state } from '../../../static/js/state/index.js';
@@ -605,6 +606,93 @@ describe('SettingsManager other-model root selects', () => {
}); });
}); });
describe('updateOtherModelsControls', () => {
const appendToggles = (...subTypes) => {
const container = document.createElement('div');
container.id = 'otherSubTypeToggles';
document.body.appendChild(container);
subTypes.forEach((subType) => {
const input = document.createElement('input');
input.type = 'checkbox';
input.value = subType;
input.dataset.otherSubtypeToggle = subType;
container.appendChild(input);
});
return container;
};
it('disables every toggle and select while the feature is off', () => {
const manager = createManager();
const container = appendToggles('vae', 'upscaler');
const selects = appendOtherRootSelects('vae', 'upscaler');
state.global.settings = {
enable_other_models: false,
enabled_other_sub_types: ['vae'],
};
manager.updateOtherModelsControls();
const vaeToggle = document.querySelector('[data-other-subtype-toggle="vae"]');
const upscalerToggle = document.querySelector('[data-other-subtype-toggle="upscaler"]');
expect(vaeToggle.checked).toBe(true);
expect(upscalerToggle.checked).toBe(false);
expect(vaeToggle.disabled).toBe(true);
expect(upscalerToggle.disabled).toBe(true);
expect(selects.vae.disabled).toBe(true);
expect(selects.upscaler.disabled).toBe(true);
expect(container.classList.contains('is-disabled')).toBe(true);
});
it('leaves enabled sub_types interactive and disables the rest', () => {
const manager = createManager();
const container = appendToggles('vae', 'upscaler');
const selects = appendOtherRootSelects('vae', 'upscaler');
state.global.settings = {
enable_other_models: true,
enabled_other_sub_types: ['vae'],
};
manager.updateOtherModelsControls();
const vaeToggle = document.querySelector('[data-other-subtype-toggle="vae"]');
const upscalerToggle = document.querySelector('[data-other-subtype-toggle="upscaler"]');
expect(vaeToggle.disabled).toBe(false);
expect(upscalerToggle.disabled).toBe(false);
expect(selects.vae.disabled).toBe(false);
expect(selects.upscaler.disabled).toBe(true);
expect(container.classList.contains('is-disabled')).toBe(false);
});
it('persists the checked sub_types as the whole allow-list', async () => {
const manager = createManager();
appendToggles('vae', 'upscaler', 'controlnet');
document.querySelector('[data-other-subtype-toggle="vae"]').checked = true;
document.querySelector('[data-other-subtype-toggle="controlnet"]').checked = true;
state.global.settings = {
enable_other_models: true,
enabled_other_sub_types: [],
};
const saveSpy = vi.spyOn(manager, 'saveSetting').mockResolvedValue();
const loadSpy = vi.spyOn(manager, 'loadOtherRoots').mockResolvedValue();
await manager.saveEnabledOtherSubTypes();
expect(saveSpy).toHaveBeenCalledWith('enabled_other_sub_types', [
'vae',
'controlnet',
]);
expect(loadSpy).toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
expect.objectContaining({ setting: 'other model types' }),
'success',
);
});
});
describe('saveOtherRootSetting', () => { describe('saveOtherRootSetting', () => {
it('read-modify-writes the default_other_roots dict and posts it whole', async () => { it('read-modify-writes the default_other_roots dict and posts it whole', async () => {
const manager = createManager(); const manager = createManager();
@@ -679,6 +767,36 @@ describe('SettingsManager other-model root selects', () => {
}); });
}); });
describe('SettingsManager Other Models nav and banner sync', () => {
it('shows or hides the Other Models nav entry', () => {
const manager = createManager();
const navItem = document.createElement('a');
navItem.id = 'otherNavItem';
document.body.appendChild(navItem);
manager.updateOtherModelsNavVisibility(false);
expect(navItem.classList.contains('nav-item--hidden')).toBe(true);
manager.updateOtherModelsNavVisibility(true);
expect(navItem.classList.contains('nav-item--hidden')).toBe(false);
});
it('drops the announcement banner only when enabling', () => {
const manager = createManager();
const spy = vi
.spyOn(bannerService, 'removeOtherModelsAnnouncement')
.mockImplementation(() => {});
manager.removeOtherModelsAnnouncement(false);
expect(spy).not.toHaveBeenCalled();
manager.removeOtherModelsAnnouncement(true);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
describe('SettingsManager recipes layout switch', () => { describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => { it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager(); const manager = createManager();
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const initializeAppMock = vi.fn();
const showToastMock = vi.fn();
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
describe('Other Models disabled page', () => {
const originalLocation = window.location;
let enableOtherModels;
let initializeOtherDisabledPage;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
initializeAppMock.mockResolvedValue(undefined);
document.body.innerHTML = '<button id="enableOtherModelsBtn"></button>';
Object.defineProperty(window, 'location', {
value: { ...originalLocation, reload: vi.fn() },
configurable: true,
writable: true,
});
({ enableOtherModels, initializeOtherDisabledPage } = await import(
'../../../static/js/other_disabled.js'
));
await initializeOtherDisabledPage();
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
writable: true,
});
delete global.fetch;
});
it('boots the shared app core so the header stays usable', () => {
expect(initializeAppMock).toHaveBeenCalledTimes(1);
});
it('enables Other Models through the settings API and reloads', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await enableOtherModels();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/settings',
expect.objectContaining({ method: 'POST' }),
);
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
enable_other_models: true,
});
expect(window.location.reload).toHaveBeenCalledTimes(1);
expect(showToastMock).not.toHaveBeenCalled();
});
it('re-enables the button and toasts when enabling fails', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'boom' }),
});
await enableOtherModels();
expect(window.location.reload).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'other.disabled.enableFailed',
expect.objectContaining({ message: 'boom' }),
'error',
);
expect(document.getElementById('enableOtherModelsBtn').disabled).toBe(false);
});
});