From 5adfa3be36775bbe37a8469b35e75b6414f2bb00 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 18 Sep 2026 19:36:19 +0800 Subject: [PATCH] feat(settings): editable model library paths for standalone mode Standalone users previously had to hand-edit settings.json to configure primary folder_paths. Add a standalone-only Model Paths section to the settings modal: - Backend exposes standalone_mode, folder_paths (with template placeholder values filtered out) and a data-driven folder_path_schema derived from OTHER_MODEL_FOLDER_SUBTYPES via GET /api/lm/settings - The new section renders multi-path editors per model type from the schema, with inline enable_other_models / sub-type controls so other model types are configured without leaving the tab - Persistent restart-required cues after a save: nav dot, inline notice and a global banner (unique id per change so dismissals don't mute future reminders) - The missing-model-paths startup banner and the Other Models no-paths empty state now deep-link into the new section instead of pointing at settings.json --- AGENTS.md | 9 + locales/de.json | 32 +- locales/en.json | 32 +- locales/es.json | 32 +- locales/fr.json | 32 +- locales/he.json | 32 +- locales/ja.json | 32 +- locales/ko.json | 32 +- locales/ru.json | 32 +- locales/zh-CN.json | 32 +- locales/zh-TW.json | 32 +- py/routes/handlers/misc_handlers.py | 25 + py/routes/other_routes.py | 4 +- py/services/settings_manager.py | 38 +- py/utils/constants.py | 23 + .../css/components/modal/settings-modal.css | 35 ++ static/js/managers/SettingsManager.js | 483 +++++++++++++++++- static/js/other_disabled.js | 25 +- static/js/state/index.js | 6 + static/js/utils/otherModels.js | 16 + templates/other.html | 24 +- .../settingsManager.modelPaths.test.js | 418 +++++++++++++++ .../frontend/pages/otherDisabledPage.test.js | 22 + .../__snapshots__/test_api_snapshots.ambr | 1 + tests/routes/test_settings_handler.py | 131 +++++ tests/test_standalone_settings.py | 18 +- tests/utils/test_constants.py | 34 +- 27 files changed, 1566 insertions(+), 66 deletions(-) create mode 100644 tests/frontend/managers/settingsManager.modelPaths.test.js diff --git a/AGENTS.md b/AGENTS.md index 76e96801..bf1eda25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,15 @@ The system runs in two modes: - Auto-saves paths to `settings.json` in ComfyUI mode - `settings.json.example` is intentionally minimal (see Important Notes); all other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`) +- **`folder_paths` vs `extra_folder_paths` — different purposes, do not conflate:** + - `folder_paths` (primary model roots): in ComfyUI plugin mode these come + from the ComfyUI host; in standalone mode they are the ONLY source of + model library paths and are currently edited by hand in `settings.json`. + - `extra_folder_paths` is a **ComfyUI-plugin-mode feature**: paths visible + ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large + model library slows ComfyUI itself down, while LoRA Manager handles large + libraries without performance issues — so users keep ComfyUI's library + small and add the bulk via `extra_folder_paths`. ### Frontend UI Architecture diff --git a/locales/de.json b/locales/de.json index d08efa88..30ad3693 100644 --- a/locales/de.json +++ b/locales/de.json @@ -382,7 +382,8 @@ "nav": { "general": "Allgemein", "interface": "Oberfläche", - "library": "Bibliothek" + "library": "Bibliothek", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "Einstellungen durchsuchen...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle." } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "Prioritäts-Tags", "description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "Keine Ordner für weitere Modelle gefunden", - "descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie die benötigten Ordnerschlüssel zum Abschnitt folder_paths Ihrer settings.json hinzu und starten Sie LoRA Manager neu.", - "hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.", "hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.", "openSettings": "Einstellungen öffnen", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "Einstellungsordner öffnen" } }, diff --git a/locales/en.json b/locales/en.json index 0bacf253..e0c1a194 100644 --- a/locales/en.json +++ b/locales/en.json @@ -382,7 +382,8 @@ "nav": { "general": "General", "interface": "Interface", - "library": "Library" + "library": "Library", + "modelPaths": "Model Paths" }, "search": { "placeholder": "Search settings...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models." } }, + "modelPaths": { + "title": "Model Library Paths", + "description": "Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "Requires restart to take effect", + "coreTypes": "Core Model Types", + "otherTypes": "Other Model Types", + "otherTypesDisabledHint": "No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "Restart required to apply path changes", + "pendingRestartBannerMessage": "Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "LoRA Paths", + "checkpoints": "Checkpoint Paths", + "unet": "Diffusion Model Paths", + "embeddings": "Embedding Paths", + "vae": "VAE Paths", + "upscale_models": "Upscaler Paths", + "text_encoders": "Text Encoder Paths", + "clip": "CLIP Paths (legacy)", + "clip_vision": "CLIP Vision Paths", + "controlnet": "ControlNet Paths" + } + }, "priorityTags": { "title": "Priority Tags", "description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "No other-model folders found", - "descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add the folder keys you need to the folder_paths section of your settings.json, then restart LoRA Manager.", - "hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.", + "descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.", "hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.", "openSettings": "Open Settings", + "openModelPaths": "Configure Model Folders", "openSettingsFolder": "Open Settings Folder" } }, diff --git a/locales/es.json b/locales/es.json index 2f6514e6..7c72308f 100644 --- a/locales/es.json +++ b/locales/es.json @@ -382,7 +382,8 @@ "nav": { "general": "General", "interface": "Interfaz", - "library": "Biblioteca" + "library": "Biblioteca", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "Buscar ajustes...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión." } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "Etiquetas prioritarias", "description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "No se encontraron carpetas de otros modelos", - "descriptionStandalone": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade las claves de carpeta que necesites a la sección folder_paths de tu settings.json y reinicia LoRA Manager.", - "hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.", "hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.", "openSettings": "Abrir configuración", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "Abrir carpeta de ajustes" } }, diff --git a/locales/fr.json b/locales/fr.json index dfdc8916..45fc33e5 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -382,7 +382,8 @@ "nav": { "general": "Général", "interface": "Interface", - "library": "Bibliothèque" + "library": "Bibliothèque", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "Rechercher dans les paramètres...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion." } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "Tags prioritaires", "description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "Aucun dossier d’autres modèles trouvé", - "descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier d’autres modèles n’a été trouvé. Ajoutez les clés de dossiers dont vous avez besoin à la section folder_paths de votre settings.json, puis redémarrez LoRA Manager.", - "hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.", "hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.", "openSettings": "Ouvrir les paramètres", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "Ouvrir le dossier des paramètres" } }, diff --git a/locales/he.json b/locales/he.json index cf45f915..2dcd54c2 100644 --- a/locales/he.json +++ b/locales/he.json @@ -382,7 +382,8 @@ "nav": { "general": "כללי", "interface": "ממשק", - "library": "ספרייה" + "library": "ספרייה", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "חיפוש בהגדרות...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה." } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "תגיות עדיפות", "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "לא נמצאו תיקיות של מודלים אחרים", - "descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את מפתחות התיקיות הדרושים למקטע folder_paths ב-settings.json והפעל מחדש את LoRA Manager.", - "hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.", "hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.", "openSettings": "פתח הגדרות", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "פתח תיקיית הגדרות" } }, diff --git a/locales/ja.json b/locales/ja.json index 195ce372..c4a1fec2 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -382,7 +382,8 @@ "nav": { "general": "一般", "interface": "インターフェース", - "library": "ライブラリ" + "library": "ライブラリ", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "設定を検索...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。" } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "優先タグ", "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "その他のモデルのフォルダーが見つかりません", - "descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルのフォルダーが見つかりません。必要なフォルダーキーをsettings.jsonのfolder_pathsセクションに追加し、LoRA Managerを再起動してください。", - "hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。", "hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。", "openSettings": "設定を開く", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "設定フォルダーを開く" } }, diff --git a/locales/ko.json b/locales/ko.json index 1205827b..cdc20748 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -382,7 +382,8 @@ "nav": { "general": "일반", "interface": "인터페이스", - "library": "라이브러리" + "library": "라이브러리", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "설정 검색...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요." } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "우선순위 태그", "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "기타 모델 폴더를 찾을 수 없습니다", - "descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 필요한 폴더 키를 settings.json의 folder_paths 섹션에 추가한 뒤 LoRA Manager를 재시작하세요.", - "hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.", "hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.", "openSettings": "설정 열기", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "설정 폴더 열기" } }, diff --git a/locales/ru.json b/locales/ru.json index 2956205f..04f793b4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -382,7 +382,8 @@ "nav": { "general": "Общее", "interface": "Интерфейс", - "library": "Библиотека" + "library": "Библиотека", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "Поиск в настройках...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models." } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "Приоритетные теги", "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "Папки других моделей не найдены", - "descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте нужные ключи папок в раздел folder_paths файла settings.json и перезапустите LoRA Manager.", - "hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.", "hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.", "openSettings": "Открыть настройки", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "Открыть папку настроек" } }, diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 4dccc4f4..98bf81ab 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -382,7 +382,8 @@ "nav": { "general": "通用", "interface": "界面", - "library": "库" + "library": "库", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "搜索设置...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。" } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "优先标签", "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "未找到其他模型文件夹", - "descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请将你需要的文件夹键添加到 settings.json 的 folder_paths 部分,然后重启 LoRA Manager。", - "hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。", "hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。", "openSettings": "打开设置", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "打开设置文件夹" } }, diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 16191a52..fb0a3ef9 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -382,7 +382,8 @@ "nav": { "general": "通用", "interface": "介面", - "library": "模型庫" + "library": "模型庫", + "modelPaths": "[TODO: Translate] Model Paths" }, "search": { "placeholder": "搜尋設定...", @@ -583,6 +584,30 @@ "checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。" } }, + "modelPaths": { + "title": "[TODO: Translate] Model Library Paths", + "description": "[TODO: Translate] Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.", + "restartRequired": "[TODO: Translate] Requires restart to take effect", + "coreTypes": "[TODO: Translate] Core Model Types", + "otherTypes": "[TODO: Translate] Other Model Types", + "otherTypesDisabledHint": "[TODO: Translate] No other model types are enabled. Turn on the types you need above to configure their folders.", + "saveSuccessRestart": "[TODO: Translate] Model library paths updated. Restart required to apply changes.", + "pendingRestartNotice": "[TODO: Translate] Path changes saved. Restart LoRA Manager for them to take effect.", + "pendingRestartBannerTitle": "[TODO: Translate] Restart required to apply path changes", + "pendingRestartBannerMessage": "[TODO: Translate] Model library paths were updated. Restart the LoRA Manager server to scan the new folders.", + "folderKeys": { + "loras": "[TODO: Translate] LoRA Paths", + "checkpoints": "[TODO: Translate] Checkpoint Paths", + "unet": "[TODO: Translate] Diffusion Model Paths", + "embeddings": "[TODO: Translate] Embedding Paths", + "vae": "[TODO: Translate] VAE Paths", + "upscale_models": "[TODO: Translate] Upscaler Paths", + "text_encoders": "[TODO: Translate] Text Encoder Paths", + "clip": "[TODO: Translate] CLIP Paths (legacy)", + "clip_vision": "[TODO: Translate] CLIP Vision Paths", + "controlnet": "[TODO: Translate] ControlNet Paths" + } + }, "priorityTags": { "title": "優先標籤", "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", @@ -1241,11 +1266,12 @@ }, "noPaths": { "title": "找不到其他模型資料夾", - "descriptionStandalone": "其他模型管理已開啟,但找不到其他模型資料夾。請將您需要的資料夾鍵加入 settings.json 的 folder_paths 區段,然後重新啟動 LoRA Manager。", - "hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。", + "descriptionStandalone": "[TODO: Translate] Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.", + "hintStandalone": "[TODO: Translate] Only enabled model types are scanned; enable the types you need under Library → Folder Settings.", "descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。", "hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。", "openSettings": "開啟設定", + "openModelPaths": "[TODO: Translate] Configure Model Folders", "openSettingsFolder": "開啟設定資料夾" } }, diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index cc8f397d..d25f8aa2 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -54,6 +54,7 @@ from ...utils.constants import ( SUPPORTED_MEDIA_EXTENSIONS, VALID_LORA_TYPES, VALID_OTHER_CIVITAI_TYPES, + folder_path_schema, ) from .model_source_handlers import ModelSourceHandler from .agent_handlers import AgentHandler @@ -1580,6 +1581,30 @@ class SettingsHandler: availability_error, ) response_data["other_models_paths_available"] = None + standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" + response_data["standalone_mode"] = standalone_mode + if standalone_mode: + # Standalone reads its model roots exclusively from + # settings.json, so the Model Paths settings UI needs the + # current values plus the editable-key schema. In plugin mode + # the paths come from the ComfyUI host and stay hidden. + folder_paths = self._settings.get("folder_paths") or {} + # A fresh install is seeded from settings.json.example, whose + # folder_paths are documentation placeholders — hide them so + # the UI starts with empty editors instead of fake paths. + get_placeholders = getattr( + self._settings, "get_template_folder_path_placeholders", None + ) + placeholders = get_placeholders() if get_placeholders else set() + if placeholders: + folder_paths = { + key: [p for p in paths if p not in placeholders] + if isinstance(paths, list) + else paths + for key, paths in folder_paths.items() + } + response_data["folder_paths"] = folder_paths + response_data["folder_path_schema"] = folder_path_schema() settings_file = getattr(self._settings, "settings_file", None) if settings_file: response_data["settings_file"] = settings_file diff --git a/py/routes/other_routes.py b/py/routes/other_routes.py index d19b7ca7..21392167 100644 --- a/py/routes/other_routes.py +++ b/py/routes/other_routes.py @@ -89,8 +89,8 @@ class OtherRoutes(BaseModelRoutes): "standalone_mode": standalone_mode, } if standalone_mode: - # The settings UI cannot edit primary folder_paths, so the empty - # state must point at the actual file the user has to edit. + # The empty state points at the Model Paths settings section and + # shows the settings.json path as a fallback reference. context["settings_file"] = getattr(self._settings, "settings_file", "") or "" return context diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 177cbab2..6314cc5c 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -19,6 +19,7 @@ from typing import ( Mapping, Optional, Sequence, + Set, Tuple, ) @@ -308,6 +309,29 @@ class SettingsManager: return payload == template + def get_template_folder_path_placeholders(self) -> Set[str]: + """Placeholder folder_paths values shipped in settings.json.example. + + A fresh standalone install is seeded from the template, so its + documentation-only placeholder paths end up in the live settings + file. The Model Paths settings UI hides them; the first real save + overwrites them via ``set("folder_paths")``. + """ + + template = self._read_template_payload() + if not template: + return set() + + folder_paths = template.get("folder_paths") + if not isinstance(folder_paths, Mapping): + return set() + + placeholders: Set[str] = set() + for value in folder_paths.values(): + paths = value if isinstance(value, list) else [value] + placeholders.update(p for p in paths if isinstance(p, str) and p) + return placeholders + def _merge_template_with_defaults( self, defaults: Dict[str, Any], template: Mapping[str, Any] ) -> Dict[str, Any]: @@ -1219,19 +1243,27 @@ class SettingsManager: if self._bootstrap_reason == "missing": message = ( "LoRA Manager created a default settings.json because no configuration was found. " - "Edit settings.json to add your model directories so library scanning can run." + "Open Settings → Model Paths to add your model directories so library scanning can run." ) else: message = ( "LoRA Manager could not locate any configured model directories. " - "Edit settings.json to add your model folders so library scanning can run." + "Open Settings → Model Paths to add your model folders so library scanning can run." ) self._add_startup_message( code="missing-model-paths", title="Model folders need setup", message=message, severity="warning", - actions=self._default_settings_actions(), + actions=[ + { + "action": "open-model-paths-settings", + "label": "Configure model folders", + "type": "primary", + "icon": "fas fa-cog", + }, + *self._default_settings_actions(), + ], dismissible=False, ) diff --git a/py/utils/constants.py b/py/utils/constants.py index 1d0305de..445d8e97 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -127,6 +127,29 @@ def other_sub_type_folder_keys() -> Dict[str, List[str]]: # Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order. OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys() +# Core folder_paths keys every LoRA Manager installation understands. +CORE_FOLDER_PATH_KEYS: List[str] = ["loras", "checkpoints", "unet", "embeddings"] + + +def folder_path_schema() -> List[Dict[str, Any]]: + """Ordered schema describing the editable folder_paths keys. + + Drives the standalone-only Model Paths settings UI: the frontend renders + one multi-path editor per entry and resolves labels via the + ``settings.modelPaths.folderKeys.`` i18n keys, so adding a new model + category is a constants + locale change only. ``sub_type`` lets the UI + hide editors for other-model categories the user has not enabled. + """ + schema: List[Dict[str, Any]] = [ + {"key": key, "category": "core", "sub_type": None} + for key in CORE_FOLDER_PATH_KEYS + ] + schema.extend( + {"key": folder_key, "category": "other", "sub_type": sub_type} + for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items() + ) + return schema + def normalize_other_sub_types(value: Any) -> List[str]: """Normalize a stored/requested enabled-sub_type list. diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 82bc6f3f..ba416b57 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -1780,3 +1780,38 @@ input:checked + .toggle-slider:before { opacity: 0.5; cursor: not-allowed; } + +/* Standalone Model Paths: pending-restart cues */ +.settings-nav-item.has-pending-restart { + position: relative; +} + +.settings-nav-item.has-pending-restart::after { + content: ''; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--lora-warning, #e67e22); +} + +.model-paths-restart-notice { + display: none; + margin-top: 8px; + padding: 10px 14px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--lora-warning, #e67e22); + background: rgba(230, 126, 34, 0.08); + color: var(--lora-warning, #e67e22); + font-size: 0.85em; + line-height: 1.4; + align-items: center; + gap: 8px; +} + +.model-paths-restart-notice.visible { + display: flex; +} diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 2e1534fb..6270233c 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -18,6 +18,16 @@ import { bannerService } from './BannerService.js'; const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']); +// Other-model sub_type -> i18n label key, mirroring the checkbox list in +// templates/components/modals/settings/library.html. +const OTHER_SUB_TYPE_LABEL_KEYS = { + vae: 'settings.folderSettings.subTypeVae', + upscaler: 'settings.folderSettings.subTypeUpscaler', + text_encoder: 'settings.folderSettings.subTypeTextEncoder', + clip_vision: 'settings.folderSettings.subTypeClipVision', + controlnet: 'settings.folderSettings.subTypeControlnet', +}; + export class SettingsManager { constructor() { this.initialized = false; @@ -26,6 +36,8 @@ export class SettingsManager { this.availableLibraries = {}; this.activeLibrary = ''; this.registeredStartupBannerIds = new Set(); + this.modelPathsSectionInitialized = false; + this.modelPathsDirty = false; // Add initialization to sync with modal state this.currentPage = document.body.dataset.page || 'loras'; @@ -78,6 +90,7 @@ export class SettingsManager { await this.applyLanguageSetting(); this.applyFrontendSettings(); + this.setupModelPathsSection(); } async applyLanguageSetting() { @@ -276,6 +289,10 @@ export class SettingsManager { case 'open-settings-modal': modalManager.showModal('settingsModal'); break; + case 'open-model-paths-settings': + modalManager.showModal('settingsModal'); + document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click(); + break; case 'open-settings-location': this.openSettingsFileLocation(); break; @@ -472,8 +489,11 @@ export class SettingsManager { const sectionId = item.dataset.section; if (!sectionId) return; - // Hide all sections - sections.forEach(section => { + // Query live instead of using the captured NodeLists: the + // standalone Model Paths section is added after this + // initializer runs, and a stale snapshot would leave it + // active forever. + document.querySelectorAll('.settings-section').forEach(section => { section.classList.remove('active'); }); @@ -484,7 +504,7 @@ export class SettingsManager { } // Update active nav state - navItems.forEach(nav => nav.classList.remove('active')); + document.querySelectorAll('.settings-nav-item').forEach(nav => nav.classList.remove('active')); item.classList.add('active'); }); }); @@ -1160,6 +1180,9 @@ export class SettingsManager { // Load extra folder paths this.loadExtraFolderPaths(); + // Load standalone model library paths (no-op in plugin mode) + this.loadModelPaths(); + // Load language setting const languageSelect = document.getElementById('languageSelect'); if (languageSelect) { @@ -1942,6 +1965,460 @@ export class SettingsManager { } } + // --- Standalone Model Paths section -------------------------------------- + // The section only exists in standalone mode, where primary folder_paths + // are read from settings.json instead of the ComfyUI host. Editors are + // rendered from the backend-provided folder_path_schema so new model + // categories appear automatically. + + setupModelPathsSection() { + if (this.modelPathsSectionInitialized) return; + if (!state.global.settings.standalone_mode) return; + + const navGroup = document.querySelector('.settings-nav-list .settings-nav-group'); + const settingsForm = document.querySelector('.settings-form'); + if (!navGroup || !settingsForm) return; + + const navButton = document.createElement('button'); + navButton.type = 'button'; + navButton.className = 'settings-nav-item'; + navButton.dataset.section = 'modelPaths'; + navButton.textContent = translate('settings.nav.modelPaths', {}, 'Model Paths'); + + const section = document.createElement('div'); + section.className = 'settings-section'; + section.id = 'section-modelPaths'; + section.dataset.section = 'modelPaths'; + section.innerHTML = ` +
+
+

+ ${translate('settings.modelPaths.title', {}, 'Model Library Paths')} + +

+
+
+
+ ${translate('settings.modelPaths.description', {}, 'Root folders LoRA Manager scans for your models. Changes take effect after restarting the server.')} +
+
+
+ + ${translate('settings.modelPaths.pendingRestartNotice', {}, 'Path changes saved. Restart LoRA Manager for them to take effect.')} +
+
+

${translate('settings.modelPaths.coreTypes', {}, 'Core Model Types')}

+
+
+
+

${translate('settings.modelPaths.otherTypes', {}, 'Other Model Types')}

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ ${this._buildModelPathSubTypeCheckboxes()} +
+
+
+
+
+ ${translate('settings.modelPaths.otherTypesDisabledHint', {}, 'No other model types are enabled. Turn on the types you need above to configure their folders.')} +
+
+
+
+ `; + + // The static nav items were bound by initializeNavigation() before the + // backend sync completed, so this dynamically added item carries its + // own handler with the same show-section behavior. + navButton.addEventListener('click', () => { + document.querySelectorAll('.settings-section').forEach((s) => s.classList.remove('active')); + section.classList.add('active'); + document.querySelectorAll('.settings-nav-item').forEach((n) => n.classList.remove('active')); + navButton.classList.add('active'); + }); + + navGroup.appendChild(navButton); + settingsForm.appendChild(section); + this.modelPathsSectionInitialized = true; + } + + _buildModelPathSubTypeCheckboxes() { + const schema = state.global.settings.folder_path_schema || []; + const subTypes = []; + schema.forEach((entry) => { + if (entry.category === 'other' && entry.sub_type && !subTypes.includes(entry.sub_type)) { + subTypes.push(entry.sub_type); + } + }); + + return subTypes.map((subType) => { + const labelKey = OTHER_SUB_TYPE_LABEL_KEYS[subType]; + const label = labelKey ? translate(labelKey, {}, subType) : subType; + return ` + + `; + }).join(''); + } + + /** + * Master toggle inside the standalone Model Paths section. Edits the same + * enable_other_models key as the Library tab control and keeps both in + * sync, then re-renders the other-model path editors in place. + */ + async handleModelPathsEnableOtherModels() { + const toggle = document.getElementById('modelPathsEnableOtherModels'); + if (!toggle) return; + + const enabled = toggle.checked; + const previous = !!state.global.settings.enable_other_models; + + try { + await this.saveSetting('enable_other_models', enabled); + // Mirror the Library tab flow: refresh its controls and roots, + // then re-render this section's editors immediately. + this.updateOtherModelsControls(); + await this.loadOtherRoots(); + this.updateOtherModelsControls(); + this.updateOtherModelsNavVisibility(enabled); + this.removeOtherModelsAnnouncement(enabled); + this.loadModelPaths(); + showToast('toast.settings.settingsUpdated', { setting: 'enable other models' }, 'success'); + } catch (error) { + toggle.checked = previous; + state.global.settings.enable_other_models = previous; + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + } + } + + /** + * Sub-type checkboxes inside the standalone Model Paths section. Saves the + * same enabled_other_sub_types allow-list as the Library tab checkboxes + * (which use data-other-subtype-toggle, so the two never mix) and + * re-renders the editors without requiring a modal reopen. + */ + async handleModelPathsSubTypeToggles() { + const values = Array.from(document.querySelectorAll('[data-model-paths-subtype]')) + .filter((input) => input.checked) + .map((input) => input.value); + + const previous = state.global.settings.enabled_other_sub_types; + + try { + await this.saveSetting('enabled_other_sub_types', values); + this.updateOtherModelsControls(); + await this.loadOtherRoots(); + this.updateOtherModelsControls(); + this.loadModelPaths(); + showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success'); + } catch (error) { + state.global.settings.enabled_other_sub_types = previous; + this.loadModelPaths(); + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + } + } + + loadModelPaths() { + if (!state.global.settings.standalone_mode) return; + + const coreHost = document.getElementById('modelPathsCoreTypes'); + const otherHost = document.getElementById('modelPathsOtherTypes'); + if (!coreHost || !otherHost) return; + + coreHost.innerHTML = ''; + otherHost.innerHTML = ''; + + const schema = state.global.settings.folder_path_schema || []; + const otherModelsEnabled = state.global.settings.enable_other_models === true; + const enabledSubTypes = new Set(state.global.settings.enabled_other_sub_types || []); + + // Keep the inline enable controls in sync with the current settings. + const masterToggle = document.getElementById('modelPathsEnableOtherModels'); + if (masterToggle) { + masterToggle.checked = otherModelsEnabled; + } + document.querySelectorAll('[data-model-paths-subtype]').forEach((input) => { + input.checked = enabledSubTypes.has(input.value); + input.disabled = !otherModelsEnabled; + }); + const subTypeToggles = document.getElementById('modelPathsSubTypeToggles'); + if (subTypeToggles) { + subTypeToggles.classList.toggle('is-disabled', !otherModelsEnabled); + } + + let otherCount = 0; + schema.forEach((entry) => { + if (entry.category === 'core') { + this._buildModelPathTypeGroup(coreHost, entry); + } else if (otherModelsEnabled && entry.sub_type && enabledSubTypes.has(entry.sub_type)) { + otherCount++; + this._buildModelPathTypeGroup(otherHost, entry); + } + }); + + const emptyHint = document.getElementById('modelPathsOtherEmpty'); + if (emptyHint) { + emptyHint.style.display = otherCount === 0 ? 'block' : 'none'; + } + + const folderPaths = state.global.settings.folder_paths || {}; + schema.forEach((entry) => { + const container = document.getElementById(`modelFolderPaths-${entry.key}`); + if (!container) return; + + container.innerHTML = ''; + const paths = folderPaths[entry.key] || []; + paths.forEach((path) => { + this.addModelFolderPathRow(entry.key, path); + }); + // No trailing empty row on load: an unconfigured type shows just + // its Add button, and removing a row never resurrects an empty one. + }); + } + + _buildModelPathTypeGroup(host, entry) { + const item = document.createElement('div'); + item.className = 'setting-item'; + + const label = translate(`settings.modelPaths.folderKeys.${entry.key}`, {}, entry.key); + item.innerHTML = ` +
+
+ +
+
+ +
+
+
+
+ `; + + host.appendChild(item); + } + + addModelFolderPathRow(key, path = '', shouldFocus = true) { + const container = document.getElementById(`modelFolderPaths-${key}`); + if (!container) return; + + const row = document.createElement('div'); + row.className = 'extra-folder-path-row mapping-row'; + + row.innerHTML = ` +
+ + +
+
+ `; + + container.appendChild(row); + + if (!path && shouldFocus) { + const input = row.querySelector('.extra-folder-path-input'); + if (input) { + setTimeout(() => input.focus(), 0); + } + } + } + + clearModelFolderPathError(input) { + input.classList.remove('has-error'); + const row = input.closest('.extra-folder-path-row'); + if (row) { + const errEl = row.querySelector('.extra-folder-path-error'); + if (errEl) { + errEl.classList.remove('visible'); + errEl.textContent = ''; + } + } + } + + _clearAllModelFolderPathErrors() { + const section = document.getElementById('section-modelPaths'); + if (!section) return; + section.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => { + input.classList.remove('has-error'); + }); + section.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => { + el.classList.remove('visible'); + el.textContent = ''; + }); + } + + _markModelFolderPathsError(key, overlappingPaths, showMessage = false) { + const container = document.getElementById(`modelFolderPaths-${key}`); + if (!container) return; + + const inputs = container.querySelectorAll('.extra-folder-path-input'); + inputs.forEach((input) => { + const val = input.value.trim(); + if (val && overlappingPaths.includes(val)) { + input.classList.add('has-error'); + if (showMessage) { + const row = input.closest('.extra-folder-path-row'); + if (row) { + const errEl = row.querySelector('.extra-folder-path-error'); + if (errEl) { + errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.'); + errEl.classList.add('visible'); + } + } + } + } + }); + } + + removeModelFolderPathRow(btn, key) { + const row = btn.closest('.extra-folder-path-row'); + if (row) { + row.remove(); + this.updateModelFolderPaths(key, { fromRemoval: true }); + } + } + + async updateModelFolderPaths(changedKey, { fromRemoval = false } = {}) { + this._clearAllModelFolderPathErrors(); + + const folderPaths = {}; + + const section = document.getElementById('section-modelPaths'); + if (!section) return; + + section.querySelectorAll('.extra-folder-paths-container[id^="modelFolderPaths-"]').forEach((container) => { + const key = container.id.slice('modelFolderPaths-'.length); + const paths = []; + container.querySelectorAll('.extra-folder-path-input').forEach((input) => { + const value = input.value.trim(); + if (value) { + paths.push(value); + } + }); + folderPaths[key] = paths; + }); + + // Client-side pre-check: checkpoints and unet must not share the same path. + const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase(); + const ckptSet = new Set((folderPaths.checkpoints || []).map(normalise)); + const unetSet = new Set((folderPaths.unet || []).map(normalise)); + const ckptOverlap = (folderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p))); + const unetOverlap = (folderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p))); + const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0; + + if (hasOverlap) { + if (changedKey === 'checkpoints') { + this._markModelFolderPathsError('checkpoints', ckptOverlap, true); + this._markModelFolderPathsError('unet', unetOverlap, false); + } else if (changedKey === 'unet') { + this._markModelFolderPathsError('unet', unetOverlap, true); + this._markModelFolderPathsError('checkpoints', ckptOverlap, false); + } else { + this._markModelFolderPathsError('checkpoints', ckptOverlap, false); + this._markModelFolderPathsError('unet', unetOverlap, false); + } + return; + } + + const currentPaths = state.global.settings.folder_paths || {}; + const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(folderPaths); + + if (!pathsChanged) { + return; + } + + state.global.settings.folder_paths = folderPaths; + + try { + await this.saveSetting('folder_paths', folderPaths); + this._markModelPathsDirty(); + showToast('settings.modelPaths.saveSuccessRestart', {}, 'success'); + + // Keep the continuous-add flow: after the user fills the trailing + // empty row, append a fresh one — but never after a removal. + const container = document.getElementById(`modelFolderPaths-${changedKey}`); + if (container && !fromRemoval) { + const inputs = container.querySelectorAll('.extra-folder-path-input'); + const hasEmptyRow = Array.from(inputs).some((input) => !input.value.trim()); + + if (!hasEmptyRow) { + this.addModelFolderPathRow(changedKey, ''); + } + } + } catch (error) { + console.error('Failed to save folder paths:', error); + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + + state.global.settings.folder_paths = currentPaths; + this.loadModelPaths(); + } + } + + /** + * Surface a persistent "restart required" cue after folder_paths changes: + * a dot on the Model Paths nav item, an inline notice in the section, and + * a global banner. The banner id is unique per change because dismissed + * banner ids persist across restarts — reusing one would mute future + * reminders. The whole state clears on the next page load (i.e. after the + * restart the user was asked to do). + */ + _markModelPathsDirty() { + if (this.modelPathsDirty) return; + this.modelPathsDirty = true; + + document.querySelector('.settings-nav-item[data-section="modelPaths"]') + ?.classList.add('has-pending-restart'); + + document.getElementById('modelPathsRestartNotice')?.classList.add('visible'); + + const bannerId = `model-paths-restart-${Date.now()}`; + bannerService.registerBanner(bannerId, { + id: bannerId, + title: translate('settings.modelPaths.pendingRestartBannerTitle', {}, 'Restart required to apply path changes'), + content: translate('settings.modelPaths.pendingRestartBannerMessage', {}, 'Model library paths were updated. Restart the LoRA Manager server to scan the new folders.'), + dismissible: true, + priority: 60, + }); + } + loadBaseModelMappings() { const mappingsContainer = document.getElementById('baseModelMappingsContainer'); if (!mappingsContainer) return; diff --git a/static/js/other_disabled.js b/static/js/other_disabled.js index 8c11f1de..557db807 100644 --- a/static/js/other_disabled.js +++ b/static/js/other_disabled.js @@ -1,6 +1,6 @@ import { appCore } from './core.js'; import { showToast } from './utils/uiHelpers.js'; -import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.js'; +import { enableOtherModels, openOtherModelsSettings, openModelPathsSettings } from './utils/otherModels.js'; /** * Other Models is an opt-in feature. While it is disabled this page renders an @@ -9,8 +9,8 @@ import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels. * * The same module backs the "enabled but no folders found" state: ComfyUI * mode points to the Settings page's Library section, while standalone mode - * (where the settings UI cannot edit primary folder paths) reveals the - * settings.json file the user must edit instead. + * points to the standalone-only Model Paths section (which edits the primary + * folder_paths) and still offers the settings.json location as a fallback. */ async function handleEnableClick() { const button = document.getElementById('enableOtherModelsBtn'); @@ -35,9 +35,17 @@ function handleOpenSettingsClick(event) { } /** - * Open the settings.json location from the standalone no-folders state. - * The settings UI cannot edit primary folder_paths, so the only useful - * action is revealing the file itself (or copying its path in Docker). + * Open Settings on the Model Paths section for the standalone "no folders + * found" state, so the missing folders can be added directly. + */ +function handleOpenModelPathsSettingsClick(event) { + event.preventDefault(); + openModelPathsSettings(); +} + +/** + * Open the settings.json location from the standalone no-folders state, + * offered as a fallback next to the Model Paths settings button. */ async function handleOpenSettingsFolderClick() { const button = document.getElementById('openSettingsFolderBtn'); @@ -84,6 +92,11 @@ async function initializeOtherDisabledPage() { settingsButton.addEventListener('click', handleOpenSettingsClick); } + const modelPathsButton = document.getElementById('openModelPathsSettingsBtn'); + if (modelPathsButton) { + modelPathsButton.addEventListener('click', handleOpenModelPathsSettingsClick); + } + const settingsFolderButton = document.getElementById('openSettingsFolderBtn'); if (settingsFolderButton) { settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick); diff --git a/static/js/state/index.js b/static/js/state/index.js index f1981a06..db516463 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -77,6 +77,12 @@ export function createDefaultSettings() { priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, default_other_roots: {}, enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'], + // Standalone-only fields populated by GET /api/lm/settings; in plugin + // mode the backend omits folder_paths/folder_path_schema and these + // defaults apply. + standalone_mode: false, + folder_paths: {}, + folder_path_schema: [], }; } diff --git a/static/js/utils/otherModels.js b/static/js/utils/otherModels.js index 40477eef..af220dcd 100644 --- a/static/js/utils/otherModels.js +++ b/static/js/utils/otherModels.js @@ -50,3 +50,19 @@ export function openOtherModelsSettings() { }); }, 100); } + +/** + * Open the settings modal on the standalone-only Model Paths section, where + * primary folder_paths are edited. The section only exists in standalone mode, + * so the nav item lookup simply no-ops elsewhere. + */ +export function openModelPathsSettings() { + const modalManager = window.modalManager; + if (modalManager && typeof modalManager.showModal === 'function') { + modalManager.showModal('settingsModal'); + } + + window.setTimeout(() => { + document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click(); + }, 100); +} diff --git a/templates/other.html b/templates/other.html index a8df5938..79bb13b4 100644 --- a/templates/other.html +++ b/templates/other.html @@ -51,18 +51,6 @@ opacity: 0.6; cursor: default; } - .other-no-paths-config { - margin: 4px 0 0; - padding: 12px 16px; - max-width: 520px; - overflow-x: auto; - text-align: left; - font-size: 12px; - line-height: 1.5; - border-radius: 6px; - background: rgba(127, 127, 127, 0.15); - border: 1px solid rgba(127, 127, 127, 0.25); - } .other-settings-file { display: flex; align-items: center; @@ -140,17 +128,13 @@

{{ t('other.noPaths.title') }}

{% if standalone_mode %}

{{ t('other.noPaths.descriptionStandalone') }}

+

{{ t('other.noPaths.hintStandalone') }}

+ {% if settings_file %}

{{ settings_file }}

{% endif %} -
"folder_paths": {
-  "vae": ["/path/to/vae"],
-  "upscale_models": ["/path/to/upscale_models"],
-  "text_encoders": ["/path/to/text_encoders"],
-  "clip_vision": ["/path/to/clip_vision"],
-  "controlnet": ["/path/to/controlnet"]
-}
-

{{ t('other.noPaths.hintStandalone') }}

diff --git a/tests/frontend/managers/settingsManager.modelPaths.test.js b/tests/frontend/managers/settingsManager.modelPaths.test.js new file mode 100644 index 00000000..a3e77a81 --- /dev/null +++ b/tests/frontend/managers/settingsManager.modelPaths.test.js @@ -0,0 +1,418 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { + closeModal: vi.fn(), + showModal: vi.fn(), + }, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../../../static/js/state/index.js', () => { + return { + state: { + global: { + settings: {}, + }, + }, + createDefaultSettings: () => ({ + language: 'en', + standalone_mode: false, + folder_paths: {}, + folder_path_schema: [], + }), + }; +}); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + resetAndReload: vi.fn(), +})); + +vi.mock('../../../static/js/utils/constants.js', () => ({ + DOWNLOAD_PATH_TEMPLATES: {}, + DEFAULT_PATH_TEMPLATES: {}, + MAPPABLE_BASE_MODELS: [], + PATH_TEMPLATE_PLACEHOLDERS: {}, + DEFAULT_PRIORITY_TAG_CONFIG: {}, + getMappableBaseModelsDynamic: () => [], +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: (_key, _params, fallback) => fallback ?? '', +})); + +vi.mock('../../../static/js/i18n/index.js', () => ({ + i18n: { + getCurrentLocale: () => 'en', + setLanguage: vi.fn().mockResolvedValue(), + }, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + configureModelCardVideo: vi.fn(), +})); + +import { SettingsManager } from '../../../static/js/managers/SettingsManager.js'; +import { bannerService } from '../../../static/js/managers/BannerService.js'; +import { state } from '../../../static/js/state/index.js'; + +const CORE_SCHEMA = [ + { key: 'loras', category: 'core', sub_type: null }, + { key: 'checkpoints', category: 'core', sub_type: null }, + { key: 'unet', category: 'core', sub_type: null }, + { key: 'embeddings', category: 'core', sub_type: null }, +]; + +const OTHER_SCHEMA = [ + { key: 'vae', category: 'other', sub_type: 'vae' }, + { key: 'controlnet', category: 'other', sub_type: 'controlnet' }, +]; + +const createManager = () => { + const initSettingsSpy = vi + .spyOn(SettingsManager.prototype, 'initializeSettings') + .mockResolvedValue(); + const initializeSpy = vi + .spyOn(SettingsManager.prototype, 'initialize') + .mockImplementation(() => {}); + + const manager = new SettingsManager(); + + initSettingsSpy.mockRestore(); + initializeSpy.mockRestore(); + + return manager; +}; + +const buildModalDom = () => { + document.body.innerHTML = ` + +
+
+
+ `; +}; + +const setStandaloneSettings = (overrides = {}) => { + state.global.settings = { + standalone_mode: true, + folder_paths: {}, + folder_path_schema: [...CORE_SCHEMA, ...OTHER_SCHEMA], + enable_other_models: false, + enabled_other_sub_types: [], + ...overrides, + }; +}; + +beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + bannerService.banners.clear(); +}); + +afterEach(() => { + delete global.fetch; +}); + +describe('SettingsManager Model Paths section', () => { + it('does not create the section in plugin mode', () => { + buildModalDom(); + state.global.settings = { standalone_mode: false }; + + const manager = createManager(); + manager.setupModelPathsSection(); + + expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).toBeNull(); + expect(document.getElementById('section-modelPaths')).toBeNull(); + }); + + it('creates nav item and section in standalone mode', () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.setupModelPathsSection(); + + expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).not.toBeNull(); + expect(document.getElementById('section-modelPaths')).not.toBeNull(); + }); + + it('switches sections when the nav item is clicked', () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.setupModelPathsSection(); + + document.querySelector('.settings-nav-item[data-section="modelPaths"]').click(); + + expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(true); + expect(document.getElementById('section-general').classList.contains('active')).toBe(false); + }); + + it('static nav clicks clear the Model Paths active state (regression)', () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.setupModelPathsSection(); + // Static nav items were bound before the Model Paths button existed; + // their handler must still clear its active state. + manager.initializeNavigation(); + + const modelPathsNav = document.querySelector('.settings-nav-item[data-section="modelPaths"]'); + modelPathsNav.click(); + expect(modelPathsNav.classList.contains('active')).toBe(true); + + document.querySelector('.settings-nav-item[data-section="general"]').click(); + + expect(modelPathsNav.classList.contains('active')).toBe(false); + expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(false); + expect(document.getElementById('section-general').classList.contains('active')).toBe(true); + }); + + it('renders core editors and only enabled other-model editors', () => { + buildModalDom(); + setStandaloneSettings({ + enable_other_models: true, + enabled_other_sub_types: ['vae'], + folder_paths: { loras: ['/models/loras'] }, + }); + + const manager = createManager(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + // Core editors always rendered + CORE_SCHEMA.forEach(({ key }) => { + expect(document.getElementById(`modelFolderPaths-${key}`)).not.toBeNull(); + }); + + // Only the enabled other-model sub-type is rendered + expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull(); + expect(document.getElementById('modelFolderPaths-controlnet')).toBeNull(); + expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('none'); + + // Existing values populate rows + const loraInput = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input'); + expect(loraInput.value).toBe('/models/loras'); + }); + + it('shows the empty hint when no other-model types are enabled', () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block'); + expect(document.getElementById('modelFolderPaths-vae')).toBeNull(); + }); + + it('renders inline enable controls synced with current settings', () => { + buildModalDom(); + setStandaloneSettings({ + enable_other_models: true, + enabled_other_sub_types: ['vae'], + }); + + const manager = createManager(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + const master = document.getElementById('modelPathsEnableOtherModels'); + expect(master).not.toBeNull(); + expect(master.checked).toBe(true); + + const vaeBox = document.querySelector('[data-model-paths-subtype="vae"]'); + const controlnetBox = document.querySelector('[data-model-paths-subtype="controlnet"]'); + expect(vaeBox.checked).toBe(true); + expect(vaeBox.disabled).toBe(false); + expect(controlnetBox.checked).toBe(false); + }); + + it('inline master toggle saves the setting and re-renders editors', async () => { + buildModalDom(); + setStandaloneSettings({ + enable_other_models: true, + enabled_other_sub_types: ['vae'], + }); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockImplementation(async (key, value) => { + state.global.settings[key] = value; + }); + manager.loadOtherRoots = vi.fn().mockResolvedValue(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull(); + + const master = document.getElementById('modelPathsEnableOtherModels'); + master.checked = false; + await manager.handleModelPathsEnableOtherModels(); + + expect(manager.saveSetting).toHaveBeenCalledWith('enable_other_models', false); + expect(state.global.settings.enable_other_models).toBe(false); + // Editors removed in place, empty hint back + expect(document.getElementById('modelFolderPaths-vae')).toBeNull(); + expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block'); + }); + + it('inline sub-type checkboxes save the allow-list and re-render editors', async () => { + buildModalDom(); + setStandaloneSettings({ + enable_other_models: true, + enabled_other_sub_types: ['vae'], + }); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockImplementation(async (key, value) => { + state.global.settings[key] = value; + }); + manager.loadOtherRoots = vi.fn().mockResolvedValue(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + document.querySelector('[data-model-paths-subtype="controlnet"]').checked = true; + await manager.handleModelPathsSubTypeToggles(); + + expect(manager.saveSetting).toHaveBeenCalledWith('enabled_other_sub_types', ['vae', 'controlnet']); + expect(document.getElementById('modelFolderPaths-controlnet')).not.toBeNull(); + }); + + it('saves collected folder paths via saveSetting', async () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + // No rows exist until the user clicks Add + expect(document.querySelector('#modelFolderPaths-loras .extra-folder-path-input')).toBeNull(); + + manager.addModelFolderPathRow('loras'); + document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras'; + + await manager.updateModelFolderPaths('loras'); + + expect(manager.saveSetting).toHaveBeenCalledWith('folder_paths', { + loras: ['/data/loras'], + checkpoints: [], + unet: [], + embeddings: [], + }); + expect(state.global.settings.folder_paths.loras).toEqual(['/data/loras']); + }); + + it('blocks saving when checkpoints and unet share a path', async () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + manager.addModelFolderPathRow('checkpoints'); + manager.addModelFolderPathRow('unet'); + document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input').value = '/same/dir'; + document.querySelector('#modelFolderPaths-unet .extra-folder-path-input').value = '/same/dir'; + + await manager.updateModelFolderPaths('checkpoints'); + + expect(manager.saveSetting).not.toHaveBeenCalled(); + const ckptInput = document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input'); + expect(ckptInput.classList.contains('has-error')).toBe(true); + }); + + it('appends a trailing empty row after filling one, but not after a removal', async () => { + buildModalDom(); + setStandaloneSettings({ folder_paths: { loras: ['/data/a'] } }); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + // Fill the trailing empty row -> save appends a fresh empty row + manager.addModelFolderPathRow('loras'); + const rows = () => document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-row'); + expect(rows()).toHaveLength(2); + rows()[1].querySelector('.extra-folder-path-input').value = '/data/b'; + await manager.updateModelFolderPaths('loras'); + expect(rows()).toHaveLength(3); + + // Removing a row never resurrects an empty row + const removeBtn = rows()[0].querySelector('.remove-path-btn'); + manager.removeModelFolderPathRow(removeBtn, 'loras'); + await vi.waitFor(() => { + expect(manager.saveSetting).toHaveBeenCalledTimes(2); + }); + // Two rows left: the saved '/data/b' plus the pre-existing trailing + // empty row — removal must not append yet another empty row. + expect(rows()).toHaveLength(2); + const emptyRows = Array.from(rows()).filter( + (row) => row.querySelector('.extra-folder-path-input').value === '', + ); + expect(emptyRows).toHaveLength(1); + }); + + it('restores previous state when saving fails', async () => { + buildModalDom(); + setStandaloneSettings({ folder_paths: { loras: ['/original'] } }); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockRejectedValue(new Error('nope')); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + const input = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input'); + input.value = '/changed'; + + await manager.updateModelFolderPaths('loras'); + + expect(state.global.settings.folder_paths).toEqual({ loras: ['/original'] }); + // Rows reloaded from restored state + const reloaded = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input'); + expect(reloaded.value).toBe('/original'); + }); + + it('marks pending-restart cues after a successful save', async () => { + buildModalDom(); + setStandaloneSettings(); + + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + manager.setupModelPathsSection(); + manager.loadModelPaths(); + + const navItem = document.querySelector('.settings-nav-item[data-section="modelPaths"]'); + expect(navItem.classList.contains('has-pending-restart')).toBe(false); + + manager.addModelFolderPathRow('loras'); + document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras'; + await manager.updateModelFolderPaths('loras'); + + expect(navItem.classList.contains('has-pending-restart')).toBe(true); + expect(document.getElementById('modelPathsRestartNotice').classList.contains('visible')).toBe(true); + + // A unique-per-change banner id: dismissing it once must not mute + // future reminders (dismissed ids persist across restarts). + const restartBanners = Array.from(bannerService.banners.keys()) + .filter((id) => id.startsWith('model-paths-restart-')); + expect(restartBanners).toHaveLength(1); + }); +}); diff --git a/tests/frontend/pages/otherDisabledPage.test.js b/tests/frontend/pages/otherDisabledPage.test.js index f8b90748..4ea4eb11 100644 --- a/tests/frontend/pages/otherDisabledPage.test.js +++ b/tests/frontend/pages/otherDisabledPage.test.js @@ -25,6 +25,7 @@ describe('Other Models disabled page', () => { document.body.innerHTML = [ '', '', + '', '', ].join(''); @@ -65,6 +66,27 @@ describe('Other Models disabled page', () => { expect(showModal).toHaveBeenCalledWith('settingsModal'); }); + it('opens the Model Paths settings from the standalone no-folders state', async () => { + const showModal = vi.fn(); + window.modalManager = { showModal }; + + const navItem = document.createElement('button'); + navItem.className = 'settings-nav-item'; + navItem.dataset.section = 'modelPaths'; + const navClick = vi.fn(); + navItem.addEventListener('click', navClick); + document.body.appendChild(navItem); + + document.getElementById('openModelPathsSettingsBtn').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + + expect(showModal).toHaveBeenCalledWith('settingsModal'); + + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(navClick).toHaveBeenCalledTimes(1); + }); + it('reveals the settings.json location from the standalone no-folders state', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/tests/routes/__snapshots__/test_api_snapshots.ambr b/tests/routes/__snapshots__/test_api_snapshots.ambr index 8cd53f95..a5f81cd7 100644 --- a/tests/routes/__snapshots__/test_api_snapshots.ambr +++ b/tests/routes/__snapshots__/test_api_snapshots.ambr @@ -30,6 +30,7 @@ 'language': 'en', 'llm_api_key_set': False, 'other_models_paths_available': False, + 'standalone_mode': False, 'theme': 'dark', }), 'success': True, diff --git a/tests/routes/test_settings_handler.py b/tests/routes/test_settings_handler.py index f7369770..a556464f 100644 --- a/tests/routes/test_settings_handler.py +++ b/tests/routes/test_settings_handler.py @@ -160,3 +160,134 @@ async def test_activate_library_unexpected_error_returns_500(monkeypatch): assert response.status == 500 assert payload["success"] is False assert payload["error"] == "bad things" + + +class DummySettingsForGet: + def __init__(self, values=None): + self._values = dict(values or {}) + self.settings_file = "/tmp/settings.json" + self.set_calls = [] + + def keys(self): + return self._values.keys() + + def get(self, key, default=None): + return self._values.get(key, default) + + def set(self, key, value): + self.set_calls.append((key, value)) + self._values[key] = value + + def get_startup_messages(self): + return [] + + +def make_get_handler(values=None) -> SettingsHandler: + return SettingsHandler( + settings_service=DummySettingsForGet(values), + metadata_provider_updater=noop_async, + downloader_factory=dummy_downloader_factory, + ) + + +@pytest.fixture +def patch_other_models_availability(monkeypatch): + monkeypatch.setattr( + config, + "get_other_models_availability", + lambda: {"available": False}, + ) + + +@pytest.mark.asyncio +async def test_get_settings_plugin_mode_hides_folder_paths( + monkeypatch, patch_other_models_availability +): + monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False) + handler = make_get_handler( + { + "language": "en", + "folder_paths": {"loras": ["/models/loras"]}, + } + ) + + response = await handler.get_settings(FakeRequest()) + payload = json_payload(response) + + assert response.status == 200 + settings = payload["settings"] + assert settings["standalone_mode"] is False + assert "folder_paths" not in settings + assert "folder_path_schema" not in settings + + +@pytest.mark.asyncio +async def test_get_settings_standalone_exposes_folder_paths_and_schema( + monkeypatch, patch_other_models_availability +): + monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1") + folder_paths = {"loras": ["/models/loras"], "vae": ["/models/vae"]} + handler = make_get_handler({"language": "en", "folder_paths": folder_paths}) + + response = await handler.get_settings(FakeRequest()) + payload = json_payload(response) + + assert response.status == 200 + settings = payload["settings"] + assert settings["standalone_mode"] is True + assert settings["folder_paths"] == folder_paths + + schema = settings["folder_path_schema"] + core_keys = [entry["key"] for entry in schema if entry["category"] == "core"] + assert core_keys == ["loras", "checkpoints", "unet", "embeddings"] + other_entries = {entry["key"]: entry for entry in schema if entry["category"] == "other"} + assert other_entries["vae"]["sub_type"] == "vae" + assert other_entries["text_encoders"]["sub_type"] == "text_encoder" + + +@pytest.mark.asyncio +async def test_update_settings_passes_folder_paths_through( + monkeypatch, patch_other_models_availability +): + monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1") + handler = make_get_handler({"folder_paths": {}}) + new_paths = {"loras": ["/models/loras"]} + + response = await handler.update_settings( + FakeRequest(json_data={"folder_paths": new_paths}) + ) + payload = json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert handler._settings.set_calls == [("folder_paths", new_paths)] + + +@pytest.mark.asyncio +async def test_get_settings_standalone_filters_template_placeholders( + monkeypatch, patch_other_models_availability +): + """Fresh installs are seeded from settings.json.example; its placeholder + paths must not show up as real values in the Model Paths UI.""" + monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1") + handler = make_get_handler( + { + "folder_paths": { + "loras": ["C:/path/to/your/loras_folder", "/real/loras"], + "vae": ["C:/path/to/another/vae_folder"], + } + } + ) + handler._settings.get_template_folder_path_placeholders = lambda: { + "C:/path/to/your/loras_folder", + "C:/path/to/another/vae_folder", + } + + response = await handler.get_settings(FakeRequest()) + payload = json_payload(response) + + assert response.status == 200 + assert payload["settings"]["folder_paths"] == { + "loras": ["/real/loras"], + "vae": [], + } diff --git a/tests/test_standalone_settings.py b/tests/test_standalone_settings.py index 90c7a480..ce9e0989 100644 --- a/tests/test_standalone_settings.py +++ b/tests/test_standalone_settings.py @@ -52,12 +52,18 @@ def test_missing_settings_creates_defaults_and_emits_warnings(tmp_path): actions = warning.get("actions") or [] assert actions == [ + { + "action": "open-model-paths-settings", + "label": "Configure model folders", + "type": "primary", + "icon": "fas fa-cog", + }, { "action": "open-settings-location", "label": "Open settings folder", "type": "primary", "icon": "fas fa-folder-open", - } + }, ] @@ -155,3 +161,13 @@ def test_apply_settings_dir_from_argv(): os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None) else: os.environ["LORA_MANAGER_SETTINGS_DIR"] = previous + + +def test_template_folder_path_placeholders_are_exposed(): + manager = get_settings_manager() + + placeholders = manager.get_template_folder_path_placeholders() + + assert "C:/path/to/your/loras_folder" in placeholders + assert "C:/path/to/another/embeddings_folder" in placeholders + assert len(placeholders) == 8 diff --git a/tests/utils/test_constants.py b/tests/utils/test_constants.py index d6616451..fbb6ce15 100644 --- a/tests/utils/test_constants.py +++ b/tests/utils/test_constants.py @@ -42,4 +42,36 @@ class TestIsEmptyPlaceholderHash: def test_rejects_non_strings(self): assert not is_empty_placeholder_hash(None) - assert not is_empty_placeholder_hash(123) \ No newline at end of file + assert not is_empty_placeholder_hash(123) + +class TestFolderPathSchema: + def test_core_keys_first_in_canonical_order(self): + from py.utils.constants import CORE_FOLDER_PATH_KEYS, folder_path_schema + + schema = folder_path_schema() + core = [entry for entry in schema if entry["category"] == "core"] + + assert [entry["key"] for entry in core] == CORE_FOLDER_PATH_KEYS + assert all(entry["sub_type"] is None for entry in core) + assert schema[: len(core)] == core + + def test_other_entries_derive_from_subtypes_table(self): + from py.utils.constants import OTHER_MODEL_FOLDER_SUBTYPES, folder_path_schema + + schema = folder_path_schema() + other = {entry["key"]: entry for entry in schema if entry["category"] == "other"} + + assert set(other) == set(OTHER_MODEL_FOLDER_SUBTYPES) + for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items(): + assert other[folder_key]["sub_type"] == sub_type + + def test_text_encoder_exposes_both_folder_keys(self): + from py.utils.constants import folder_path_schema + + text_encoder_keys = [ + entry["key"] + for entry in folder_path_schema() + if entry["sub_type"] == "text_encoder" + ] + + assert text_encoder_keys == ["text_encoders", "clip"]