Compare commits

..

4 Commits

Author SHA1 Message Date
Will Miao 3b9e8efb3d feat(banners): rotate active banners one at a time with a pager
Stacking every active banner vertically ate header height when several
were active at once. Only the highest-priority banner renders now; a
‹ 1/N › pager cycles through the rest, and all active banners are still
recorded in the notification-center history so cycled-away ones stay
reachable. Newly registered banners preempt the displayed one only when
they outrank it.

Also fix the startup flow: the restart-required banner (now priority 80)
outranks the model-folders setup warning (60), and the setup banner is
retired once a non-empty folder path is saved.

New banners.pager.* keys translated in all 9 locales.
2026-09-18 21:40:33 +08:00
Will Miao d45a523fb5 feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic
POST /api/lm/browse-directory endpoint (browse logic extracted from the
recipe batch-import handler into py/utils/directory_browser.py) and wire
a browse button plus advisory validate-path feedback (POST
/api/lm/validate-path) into the settings path inputs: recipes path,
example images path/local root, and the extra-folder/model-path rows.

The browse button insets into the right edge of static inputs so narrow
settings rows keep their single-control layout.

Translations for the new settings.directoryPicker and
settings.pathValidation keys are filled in for all 9 locales.
2026-09-18 21:05:32 +08:00
Will Miao 6dc9f34f7d i18n: translate the Model Paths settings section into all locales 2026-09-18 19:43:46 +08:00
Will Miao 5adfa3be36 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
2026-09-18 19:36:19 +08:00
41 changed files with 3940 additions and 257 deletions
+9
View File
@@ -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
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "Allgemein",
"interface": "Oberfläche",
"library": "Bibliothek"
"library": "Bibliothek",
"modelPaths": "Modellpfade"
},
"search": {
"placeholder": "Einstellungen durchsuchen...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
}
},
"modelPaths": {
"title": "Modellbibliothek-Pfade",
"description": "Stammordner, die LoRA Manager nach Ihren Modellen durchsucht. Dies sind die primären Modellspeicherorte, die im Standalone-Modus aus der settings.json gelesen werden.",
"restartRequired": "Neustart erforderlich, damit die Änderung wirksam wird",
"coreTypes": "Kern-Modelltypen",
"otherTypes": "Weitere Modelltypen",
"otherTypesDisabledHint": "Es sind keine weiteren Modelltypen aktiviert. Aktivieren Sie oben die benötigten Typen, um deren Ordner zu konfigurieren.",
"saveSuccessRestart": "Modellbibliothek-Pfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"pendingRestartNotice": "Pfadänderungen gespeichert. Starten Sie LoRA Manager neu, damit sie wirksam werden.",
"pendingRestartBannerTitle": "Neustart erforderlich, um Pfadänderungen anzuwenden",
"pendingRestartBannerMessage": "Die Modellbibliothek-Pfade wurden aktualisiert. Starten Sie den LoRA Manager-Server neu, um die neuen Ordner zu scannen.",
"folderKeys": {
"loras": "LoRA-Pfade",
"checkpoints": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embeddings": "Embedding-Pfade",
"vae": "VAE-Pfade",
"upscale_models": "Upscaler-Pfade",
"text_encoders": "Text-Encoder-Pfade",
"clip": "CLIP-Pfade (Legacy)",
"clip_vision": "CLIP-Vision-Pfade",
"controlnet": "ControlNet-Pfade"
}
},
"directoryPicker": {
"title": "Ordner durchsuchen",
"selectFolder": "Diesen Ordner auswählen",
"goUp": "Nach oben",
"pathPlaceholder": "Pfad eingeben...",
"go": "Los",
"emptyFolder": "Keine Unterordner",
"loadError": "Verzeichnis konnte nicht geladen werden"
},
"pathValidation": {
"valid": "Pfad ist gültig",
"pathNotFound": "Pfad existiert nicht",
"notADirectory": "Kein Verzeichnis",
"notReadable": "Pfad ist nicht lesbar",
"notWritable": "Pfad ist nicht beschreibbar"
},
"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 +1282,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": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie Ihre Modellordner unter Einstellungen → Modellpfade hinzu und starten Sie LoRA Manager anschließend neu.",
"hintStandalone": "Es werden nur aktivierte Modelltypen gescannt. Aktivieren Sie die benötigten Typen unter Bibliothek → Standard-Roots.",
"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": "Modellordner konfigurieren",
"openSettingsFolder": "Einstellungsordner öffnen"
}
},
@@ -2699,6 +2741,11 @@
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
"enable": "Weitere Modelle aktivieren",
"openSettings": "Einstellungen öffnen"
},
"pager": {
"previous": "Vorherige Mitteilung",
"next": "Nächste Mitteilung",
"position": "Mitteilung {current} von {total}"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "General",
"interface": "Interface",
"library": "Library"
"library": "Library",
"modelPaths": "Model Paths"
},
"search": {
"placeholder": "Search settings...",
@@ -583,6 +584,46 @@
"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"
}
},
"directoryPicker": {
"title": "Browse Folders",
"selectFolder": "Select This Folder",
"goUp": "Up",
"pathPlaceholder": "Enter path...",
"go": "Go",
"emptyFolder": "No subfolders",
"loadError": "Failed to load directory"
},
"pathValidation": {
"valid": "Path is valid",
"pathNotFound": "Path does not exist",
"notADirectory": "Not a directory",
"notReadable": "Path is not readable",
"notWritable": "Path is not writable"
},
"priorityTags": {
"title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
@@ -1241,11 +1282,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"
}
},
@@ -2699,6 +2741,11 @@
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
"enable": "Enable Other Models",
"openSettings": "Open Settings"
},
"pager": {
"previous": "Previous message",
"next": "Next message",
"position": "Message {current} of {total}"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "General",
"interface": "Interfaz",
"library": "Biblioteca"
"library": "Biblioteca",
"modelPaths": "Rutas de modelos"
},
"search": {
"placeholder": "Buscar ajustes...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
}
},
"modelPaths": {
"title": "Rutas de la biblioteca de modelos",
"description": "Carpetas raíz que LoRA Manager escanea en busca de tus modelos. Son las ubicaciones de modelos principales leídas de settings.json en modo independiente.",
"restartRequired": "Requiere reiniciar para que surta efecto",
"coreTypes": "Tipos de modelos principales",
"otherTypes": "Otros tipos de modelos",
"otherTypesDisabledHint": "No hay habilitado ningún otro tipo de modelo. Activa los tipos que necesites arriba para configurar sus carpetas.",
"saveSuccessRestart": "Rutas de la biblioteca de modelos actualizadas. Se requiere reinicio para aplicar los cambios.",
"pendingRestartNotice": "Cambios de rutas guardados. Reinicia LoRA Manager para que surtan efecto.",
"pendingRestartBannerTitle": "Se requiere reinicio para aplicar los cambios de rutas",
"pendingRestartBannerMessage": "Se actualizaron las rutas de la biblioteca de modelos. Reinicia el servidor de LoRA Manager para escanear las nuevas carpetas.",
"folderKeys": {
"loras": "Rutas de LoRA",
"checkpoints": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embeddings": "Rutas de Embedding",
"vae": "Rutas de VAE",
"upscale_models": "Rutas de Upscaler",
"text_encoders": "Rutas de Text Encoder",
"clip": "Rutas de CLIP (heredadas)",
"clip_vision": "Rutas de CLIP Vision",
"controlnet": "Rutas de ControlNet"
}
},
"directoryPicker": {
"title": "Explorar carpetas",
"selectFolder": "Seleccionar esta carpeta",
"goUp": "Subir",
"pathPlaceholder": "Introducir ruta...",
"go": "Ir",
"emptyFolder": "No hay subcarpetas",
"loadError": "Error al cargar el directorio"
},
"pathValidation": {
"valid": "La ruta es válida",
"pathNotFound": "La ruta no existe",
"notADirectory": "No es un directorio",
"notReadable": "La ruta no es legible",
"notWritable": "La ruta no es escribible"
},
"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 +1282,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": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade tus carpetas de modelos en Configuración → Rutas de modelos y reinicia LoRA Manager.",
"hintStandalone": "Solo se escanean los tipos de modelos habilitados; activa los tipos que necesites en Biblioteca → Raíces predeterminadas.",
"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": "Configurar carpetas de modelos",
"openSettingsFolder": "Abrir carpeta de ajustes"
}
},
@@ -2699,6 +2741,11 @@
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
"enable": "Activar otros modelos",
"openSettings": "Abrir configuración"
},
"pager": {
"previous": "Notificación anterior",
"next": "Notificación siguiente",
"position": "Notificación {current} de {total}"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "Général",
"interface": "Interface",
"library": "Bibliothèque"
"library": "Bibliothèque",
"modelPaths": "Chemins de modèles"
},
"search": {
"placeholder": "Rechercher dans les paramètres...",
@@ -583,6 +584,46 @@
"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": "Chemins de la bibliothèque de modèles",
"description": "Dossiers racine que LoRA Manager analyse pour trouver vos modèles. Ce sont les emplacements de modèles principaux lus depuis settings.json en mode autonome.",
"restartRequired": "Un redémarrage est requis pour appliquer les changements",
"coreTypes": "Types de modèles principaux",
"otherTypes": "Autres types de modèles",
"otherTypesDisabledHint": "Aucun autre type de modèle nest activé. Activez les types dont vous avez besoin ci-dessus pour configurer leurs dossiers.",
"saveSuccessRestart": "Chemins de la bibliothèque de modèles mis à jour. Redémarrage requis pour appliquer les changements.",
"pendingRestartNotice": "Changements de chemins enregistrés. Redémarrez LoRA Manager pour quils prennent effet.",
"pendingRestartBannerTitle": "Redémarrage requis pour appliquer les changements de chemins",
"pendingRestartBannerMessage": "Les chemins de la bibliothèque de modèles ont été mis à jour. Redémarrez le serveur LoRA Manager pour analyser les nouveaux dossiers.",
"folderKeys": {
"loras": "Chemins LoRA",
"checkpoints": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embeddings": "Chemins Embedding",
"vae": "Chemins VAE",
"upscale_models": "Chemins Upscaler",
"text_encoders": "Chemins Text Encoder",
"clip": "Chemins CLIP (hérité)",
"clip_vision": "Chemins CLIP Vision",
"controlnet": "Chemins ControlNet"
}
},
"directoryPicker": {
"title": "Parcourir les dossiers",
"selectFolder": "Sélectionner ce dossier",
"goUp": "Remonter",
"pathPlaceholder": "Saisir un chemin...",
"go": "Aller",
"emptyFolder": "Aucun sous-dossier",
"loadError": "Échec du chargement du dossier"
},
"pathValidation": {
"valid": "Le chemin est valide",
"pathNotFound": "Le chemin nexiste pas",
"notADirectory": "Nest pas un dossier",
"notReadable": "Le chemin nest pas lisible",
"notWritable": "Le chemin nest pas accessible en écriture"
},
"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 +1282,12 @@
},
"noPaths": {
"title": "Aucun dossier dautres modèles trouvé",
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier dautres modèles na é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": "La gestion des autres modèles est activée, mais aucun dossier dautres modèles na été trouvé. Ajoutez vos dossiers de modèles dans Paramètres → Chemins de modèles, puis redémarrez LoRA Manager.",
"hintStandalone": "Seuls les types de modèles activés sont analysés ; activez les types dont vous avez besoin dans Bibliothèque → Racines par défaut.",
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste 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": "Configurer les dossiers de modèles",
"openSettingsFolder": "Ouvrir le dossier des paramètres"
}
},
@@ -2699,6 +2741,11 @@
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
"enable": "Activer les autres modèles",
"openSettings": "Ouvrir les paramètres"
},
"pager": {
"previous": "Message précédent",
"next": "Message suivant",
"position": "Message {current} sur {total}"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "כללי",
"interface": "ממשק",
"library": "ספרייה"
"library": "ספרייה",
"modelPaths": "נתיבי מודלים"
},
"search": {
"placeholder": "חיפוש בהגדרות...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
}
},
"modelPaths": {
"title": "נתיבי ספריית המודלים",
"description": "תיקיות שורש ש-LoRA Manager סורק לאיתור המודלים שלך. אלו מיקומי המודלים הראשיים הנקראים מ-settings.json במצב עצמאי.",
"restartRequired": "נדרש אתחול כדי שהשינוי ייכנס לתוקף",
"coreTypes": "סוגי מודלים מרכזיים",
"otherTypes": "סוגי מודלים אחרים",
"otherTypesDisabledHint": "לא מופעלים סוגי מודלים אחרים. הפעל למעלה את הסוגים הדרושים לך כדי להגדיר את התיקיות שלהם.",
"saveSuccessRestart": "נתיבי ספריית המודלים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"pendingRestartNotice": "שינויי הנתיבים נשמרו. הפעל מחדש את LoRA Manager כדי שייכנסו לתוקף.",
"pendingRestartBannerTitle": "נדרשת הפעלה מחדש כדי להחיל את שינויי הנתיבים",
"pendingRestartBannerMessage": "נתיבי ספריית המודלים עודכנו. הפעל מחדש את שרת LoRA Manager כדי לסרוק את התיקיות החדשות.",
"folderKeys": {
"loras": "נתיבי LoRA",
"checkpoints": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embeddings": "נתיבי Embedding",
"vae": "נתיבי VAE",
"upscale_models": "נתיבי Upscaler",
"text_encoders": "נתיבי Text Encoder",
"clip": "נתיבי CLIP (ישן)",
"clip_vision": "נתיבי CLIP Vision",
"controlnet": "נתיבי ControlNet"
}
},
"directoryPicker": {
"title": "עיון בתיקיות",
"selectFolder": "בחר תיקייה זו",
"goUp": "למעלה",
"pathPlaceholder": "הזן נתיב...",
"go": "עבור",
"emptyFolder": "אין תתי-תיקיות",
"loadError": "טעינת התיקייה נכשלה"
},
"pathValidation": {
"valid": "הנתיב תקין",
"pathNotFound": "הנתיב לא קיים",
"notADirectory": "לא תיקייה",
"notReadable": "הנתיב לא ניתן לקריאה",
"notWritable": "הנתיב לא ניתן לכתיבה"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -1241,11 +1282,12 @@
},
"noPaths": {
"title": "לא נמצאו תיקיות של מודלים אחרים",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את מפתחות התיקיות הדרושים למקטע folder_paths ב-settings.json והפעל מחדש את LoRA Manager.",
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את תיקיות המודלים שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
"hintStandalone": "נסרקים רק סוגי מודלים מופעלים; הפעל את הסוגים הדרושים לך תחת ספרייה > תיקיות ברירת מחדל.",
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
"openSettings": "פתח הגדרות",
"openModelPaths": "הגדר תיקיות מודלים",
"openSettingsFolder": "פתח תיקיית הגדרות"
}
},
@@ -2699,6 +2741,11 @@
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
"enable": "הפעל מודלים אחרים",
"openSettings": "פתח הגדרות"
},
"pager": {
"previous": "הודעה קודמת",
"next": "הודעה הבאה",
"position": "הודעה {current} מתוך {total}"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "一般",
"interface": "インターフェース",
"library": "ライブラリ"
"library": "ライブラリ",
"modelPaths": "モデルパス"
},
"search": {
"placeholder": "設定を検索...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
}
},
"modelPaths": {
"title": "モデルライブラリパス",
"description": "LoRA Managerがモデルをスキャンするルートフォルダーです。スタンドアロンモードでは settings.json から読み込まれる主要なモデルの場所になります。",
"restartRequired": "変更を有効にするには再起動が必要です",
"coreTypes": "コアモデルタイプ",
"otherTypes": "その他のモデルタイプ",
"otherTypesDisabledHint": "その他のモデルタイプが有効になっていません。フォルダーを設定するには、上で必要なタイプをオンにしてください。",
"saveSuccessRestart": "モデルライブラリパスを更新しました。変更を適用するには再起動が必要です。",
"pendingRestartNotice": "パスの変更を保存しました。変更を有効にするにはLoRA Managerを再起動してください。",
"pendingRestartBannerTitle": "パスの変更を適用するには再起動が必要です",
"pendingRestartBannerMessage": "モデルライブラリパスが更新されました。新しいフォルダーをスキャンするにはLoRA Managerサーバーを再起動してください。",
"folderKeys": {
"loras": "LoRAパス",
"checkpoints": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embeddings": "Embeddingパス",
"vae": "VAEパス",
"upscale_models": "Upscalerパス",
"text_encoders": "Text Encoderパス",
"clip": "CLIPパス(レガシー)",
"clip_vision": "CLIP Visionパス",
"controlnet": "ControlNetパス"
}
},
"directoryPicker": {
"title": "フォルダを参照",
"selectFolder": "このフォルダを選択",
"goUp": "上へ",
"pathPlaceholder": "パスを入力...",
"go": "移動",
"emptyFolder": "サブフォルダがありません",
"loadError": "ディレクトリの読み込みに失敗しました"
},
"pathValidation": {
"valid": "パスは有効です",
"pathNotFound": "パスが存在しません",
"notADirectory": "ディレクトリではありません",
"notReadable": "パスは読み取れません",
"notWritable": "パスは書き込めません"
},
"priorityTags": {
"title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -1241,11 +1282,12 @@
},
"noPaths": {
"title": "その他のモデルのフォルダーが見つかりません",
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりません。必要なフォルダーキーをsettings.jsonのfolder_pathsセクションに追加し、LoRA Managerを再起動してください。",
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりませんでした。「設定 > モデルパス」でモデルフォルダーを追加し、LoRA Managerを再起動してください。",
"hintStandalone": "有効になっているモデルタイプのみがスキャンされます。必要なタイプは「ライブラリ > デフォルトルート」で有効にしてください。",
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
"openSettings": "設定を開く",
"openModelPaths": "モデルフォルダーを設定",
"openSettingsFolder": "設定フォルダーを開く"
}
},
@@ -2699,6 +2741,11 @@
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enable": "その他のモデルを有効にする",
"openSettings": "設定を開く"
},
"pager": {
"previous": "前の通知",
"next": "次の通知",
"position": "{total} 件中 {current} 件目の通知"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "일반",
"interface": "인터페이스",
"library": "라이브러리"
"library": "라이브러리",
"modelPaths": "모델 경로"
},
"search": {
"placeholder": "설정 검색...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
}
},
"modelPaths": {
"title": "모델 라이브러리 경로",
"description": "LoRA Manager가 모델을 스캔하는 루트 폴더입니다. 독립 실행 모드에서는 settings.json에서 읽어오는 기본 모델 위치입니다.",
"restartRequired": "변경 사항을 적용하려면 재시작이 필요합니다",
"coreTypes": "핵심 모델 유형",
"otherTypes": "기타 모델 유형",
"otherTypesDisabledHint": "활성화된 기타 모델 유형이 없습니다. 위에서 필요한 유형을 켜면 해당 폴더를 구성할 수 있습니다.",
"saveSuccessRestart": "모델 라이브러리 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"pendingRestartNotice": "경로 변경 사항이 저장되었습니다. 적용하려면 LoRA Manager를 재시작하세요.",
"pendingRestartBannerTitle": "경로 변경 사항을 적용하려면 재시작이 필요합니다",
"pendingRestartBannerMessage": "모델 라이브러리 경로가 업데이트되었습니다. 새 폴더를 스캔하려면 LoRA Manager 서버를 재시작하세요.",
"folderKeys": {
"loras": "LoRA 경로",
"checkpoints": "Checkpoint 경로",
"unet": "Diffusion Model 경로",
"embeddings": "Embedding 경로",
"vae": "VAE 경로",
"upscale_models": "Upscaler 경로",
"text_encoders": "Text Encoder 경로",
"clip": "CLIP 경로 (레거시)",
"clip_vision": "CLIP Vision 경로",
"controlnet": "ControlNet 경로"
}
},
"directoryPicker": {
"title": "폴더 찾아보기",
"selectFolder": "이 폴더 선택",
"goUp": "위로",
"pathPlaceholder": "경로 입력...",
"go": "이동",
"emptyFolder": "하위 폴더 없음",
"loadError": "디렉터리를 불러오지 못했습니다"
},
"pathValidation": {
"valid": "유효한 경로입니다",
"pathNotFound": "경로가 존재하지 않습니다",
"notADirectory": "디렉터리가 아닙니다",
"notReadable": "경로를 읽을 수 없습니다",
"notWritable": "경로에 쓸 수 없습니다"
},
"priorityTags": {
"title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -1241,11 +1282,12 @@
},
"noPaths": {
"title": "기타 모델 폴더를 찾을 수 없습니다",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 필요한 폴더 키를 settings.json의 folder_paths 섹션에 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 모델 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "활성화된 모델 유형만 스캔됩니다. 라이브러리 → 기본 루트에서 필요한 유형을 활성화하세요.",
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
"openSettings": "설정 열기",
"openModelPaths": "모델 폴더 구성",
"openSettingsFolder": "설정 폴더 열기"
}
},
@@ -2699,6 +2741,11 @@
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
"enable": "기타 모델 활성화",
"openSettings": "설정 열기"
},
"pager": {
"previous": "이전 알림",
"next": "다음 알림",
"position": "전체 {total}개 중 {current}번째 알림"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "Общее",
"interface": "Интерфейс",
"library": "Библиотека"
"library": "Библиотека",
"modelPaths": "Пути к моделям"
},
"search": {
"placeholder": "Поиск в настройках...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
}
},
"modelPaths": {
"title": "Пути библиотеки моделей",
"description": "Корневые папки, которые LoRA Manager сканирует в поисках ваших моделей. В автономном режиме это основные расположения моделей, считываемые из settings.json.",
"restartRequired": "Требуется перезапуск, чтобы изменения вступили в силу",
"coreTypes": "Основные типы моделей",
"otherTypes": "Другие типы моделей",
"otherTypesDisabledHint": "Другие типы моделей не включены. Включите нужные типы выше, чтобы настроить их папки.",
"saveSuccessRestart": "Пути библиотеки моделей обновлены. Требуется перезапуск для применения изменений.",
"pendingRestartNotice": "Изменения путей сохранены. Перезапустите LoRA Manager, чтобы они вступили в силу.",
"pendingRestartBannerTitle": "Требуется перезапуск для применения изменений путей",
"pendingRestartBannerMessage": "Пути библиотеки моделей обновлены. Перезапустите сервер LoRA Manager, чтобы просканировать новые папки.",
"folderKeys": {
"loras": "Пути LoRA",
"checkpoints": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embeddings": "Пути Embedding",
"vae": "Пути VAE",
"upscale_models": "Пути Upscaler",
"text_encoders": "Пути Text Encoder",
"clip": "Пути CLIP (устаревшие)",
"clip_vision": "Пути CLIP Vision",
"controlnet": "Пути ControlNet"
}
},
"directoryPicker": {
"title": "Обзор папок",
"selectFolder": "Выбрать эту папку",
"goUp": "Вверх",
"pathPlaceholder": "Введите путь...",
"go": "Перейти",
"emptyFolder": "Нет подпапок",
"loadError": "Не удалось загрузить каталог"
},
"pathValidation": {
"valid": "Путь действителен",
"pathNotFound": "Путь не существует",
"notADirectory": "Не является каталогом",
"notReadable": "Путь недоступен для чтения",
"notWritable": "Путь недоступен для записи"
},
"priorityTags": {
"title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -1241,11 +1282,12 @@
},
"noPaths": {
"title": "Папки других моделей не найдены",
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте нужные ключи папок в раздел folder_paths файла settings.json и перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте свои папки моделей в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только включённые типы моделей; включите нужные типы в разделе «Библиотека → Корневые папки».",
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
"openSettings": "Открыть настройки",
"openModelPaths": "Настроить папки моделей",
"openSettingsFolder": "Открыть папку настроек"
}
},
@@ -2699,6 +2741,11 @@
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
"enable": "Включить другие модели",
"openSettings": "Открыть настройки"
},
"pager": {
"previous": "Предыдущее уведомление",
"next": "Следующее уведомление",
"position": "Уведомление {current} из {total}"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "通用",
"interface": "界面",
"library": "库"
"library": "库",
"modelPaths": "模型路径"
},
"search": {
"placeholder": "搜索设置...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
}
},
"modelPaths": {
"title": "模型库路径",
"description": "LoRA Manager 扫描模型所用的根文件夹。独立模式下,这些是从 settings.json 读取的主要模型位置。",
"restartRequired": "需要重启才能生效",
"coreTypes": "核心模型类型",
"otherTypes": "其他模型类型",
"otherTypesDisabledHint": "未启用任何其他模型类型。请在上方启用你需要的类型,然后为其配置文件夹。",
"saveSuccessRestart": "模型库路径已更新,需要重启才能生效。",
"pendingRestartNotice": "路径更改已保存。重启 LoRA Manager 后生效。",
"pendingRestartBannerTitle": "需要重启以应用路径更改",
"pendingRestartBannerMessage": "模型库路径已更新。请重启 LoRA Manager 服务器以扫描新文件夹。",
"folderKeys": {
"loras": "LoRA 路径",
"checkpoints": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embeddings": "Embedding 路径",
"vae": "VAE 路径",
"upscale_models": "Upscaler 路径",
"text_encoders": "Text Encoder 路径",
"clip": "CLIP 路径(旧版)",
"clip_vision": "CLIP Vision 路径",
"controlnet": "ControlNet 路径"
}
},
"directoryPicker": {
"title": "浏览文件夹",
"selectFolder": "选择此文件夹",
"goUp": "上级目录",
"pathPlaceholder": "输入路径...",
"go": "跳转",
"emptyFolder": "没有子文件夹",
"loadError": "目录加载失败"
},
"pathValidation": {
"valid": "路径有效",
"pathNotFound": "路径不存在",
"notADirectory": "不是一个目录",
"notReadable": "路径不可读",
"notWritable": "路径不可写"
},
"priorityTags": {
"title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -1241,11 +1282,12 @@
},
"noPaths": {
"title": "未找到其他模型文件夹",
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请将你需要的文件夹键添加到 settings.json 的 folder_paths 部分,然后重启 LoRA Manager。",
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请在“设置 → 模型路径”中添加你的模型文件夹,然后重启 LoRA Manager。",
"hintStandalone": "仅扫描已启用的模型类型;请在“库 → 默认根目录”中启用你需要的类型。",
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
"openSettings": "打开设置",
"openModelPaths": "配置模型文件夹",
"openSettingsFolder": "打开设置文件夹"
}
},
@@ -2699,6 +2741,11 @@
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enable": "启用其他模型",
"openSettings": "打开设置"
},
"pager": {
"previous": "上一条通知",
"next": "下一条通知",
"position": "第 {current} 条通知,共 {total} 条"
}
}
}
+50 -3
View File
@@ -382,7 +382,8 @@
"nav": {
"general": "通用",
"interface": "介面",
"library": "模型庫"
"library": "模型庫",
"modelPaths": "模型路徑"
},
"search": {
"placeholder": "搜尋設定...",
@@ -583,6 +584,46 @@
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
}
},
"modelPaths": {
"title": "模型庫路徑",
"description": "LoRA Manager 掃描您模型的根目錄資料夾。這些是獨立模式下從 settings.json 讀取的主要模型位置。",
"restartRequired": "需要重新啟動才能生效",
"coreTypes": "核心模型類型",
"otherTypes": "其他模型類型",
"otherTypesDisabledHint": "尚未啟用任何其他模型類型。請在上方開啟您需要的類型,以設定其資料夾。",
"saveSuccessRestart": "模型庫路徑已更新,需要重新啟動才能生效。",
"pendingRestartNotice": "路徑變更已儲存。請重新啟動 LoRA Manager 以使其生效。",
"pendingRestartBannerTitle": "需要重新啟動才能套用路徑變更",
"pendingRestartBannerMessage": "模型庫路徑已更新。請重新啟動 LoRA Manager 伺服器以掃描新的資料夾。",
"folderKeys": {
"loras": "LoRA 路徑",
"checkpoints": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embeddings": "Embedding 路徑",
"vae": "VAE 路徑",
"upscale_models": "Upscaler 路徑",
"text_encoders": "Text Encoder 路徑",
"clip": "CLIP 路徑(舊版)",
"clip_vision": "CLIP Vision 路徑",
"controlnet": "ControlNet 路徑"
}
},
"directoryPicker": {
"title": "瀏覽資料夾",
"selectFolder": "選擇此資料夾",
"goUp": "上一層",
"pathPlaceholder": "輸入路徑...",
"go": "前往",
"emptyFolder": "沒有子資料夾",
"loadError": "目錄載入失敗"
},
"pathValidation": {
"valid": "路徑有效",
"pathNotFound": "路徑不存在",
"notADirectory": "不是目錄",
"notReadable": "路徑無法讀取",
"notWritable": "路徑無法寫入"
},
"priorityTags": {
"title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -1241,11 +1282,12 @@
},
"noPaths": {
"title": "找不到其他模型資料夾",
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型資料夾。請將您需要的資料夾鍵加入 settings.json 的 folder_paths 區段,然後重新啟動 LoRA Manager。",
"hintStandalone": "會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型資料夾。請在「設定 > 模型路徑」中加入您的模型資料夾,然後重新啟動 LoRA Manager。",
"hintStandalone": "會掃描已啟用的模型類型;請在「模型庫 > 預設根目錄」中啟用您需要的類型。",
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
"openSettings": "開啟設定",
"openModelPaths": "設定模型資料夾",
"openSettingsFolder": "開啟設定資料夾"
}
},
@@ -2699,6 +2741,11 @@
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enable": "啟用其他模型",
"openSettings": "開啟設定"
},
"pager": {
"previous": "上一則通知",
"next": "下一則通知",
"position": "第 {current} 則通知,共 {total} 則"
}
}
}
+98
View File
@@ -54,12 +54,14 @@ 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
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.directory_browser import browse_directory
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
@@ -1580,6 +1582,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
@@ -3472,6 +3498,76 @@ class FileSystemHandler:
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory for the settings-UI directory picker."""
try:
data = await request.json()
payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to browse directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def validate_path(self, request: web.Request) -> web.Response:
"""Validate a filesystem path for the settings UI.
A well-formed request always returns HTTP 200; invalid paths are
reported via ``error_code`` in the payload. HTTP 400 is reserved for
malformed requests (missing path, invalid JSON).
"""
try:
data = await request.json()
raw_path = data.get("path")
expect = data.get("expect", "directory")
if not raw_path or not isinstance(raw_path, str):
return web.json_response(
{"success": False, "error": "Missing path parameter"}, status=400
)
# Business path convention: abspath only, never realpath.
path = os.path.abspath(os.path.expanduser(raw_path))
exists = os.path.exists(path)
is_directory = os.path.isdir(path) if exists else False
readable = bool(exists and os.access(path, os.R_OK))
writable = bool(exists and os.access(path, os.W_OK))
error_code = None
if not exists:
error_code = "path_not_found"
elif expect == "directory" and not is_directory:
error_code = "not_a_directory"
elif expect == "file" and not os.path.isfile(path):
error_code = "not_a_file"
elif not readable:
error_code = "not_readable"
elif not writable:
error_code = "not_writable"
return web.json_response(
{
"success": True,
"path": path,
"exists": exists,
"is_directory": is_directory,
"readable": readable,
"writable": writable,
"error_code": error_code,
}
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to validate path: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex."""
@@ -4116,6 +4212,8 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters,
+7 -158
View File
@@ -9,7 +9,6 @@ import re
import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
from aiohttp import web
@@ -34,6 +33,7 @@ from ...utils.civitai_utils import (
rewrite_preview_url,
)
from ...utils.constants import NSFW_LEVELS
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger
@@ -3124,11 +3124,10 @@ class RecipeWorkflowHandler:
class BatchImportHandler:
"""Handle batch import operations for recipes."""
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
# Virtual path token for the Windows drive list. Kept as a class
# attribute for backwards compatibility; the canonical definition lives
# in py/utils/directory_browser.py.
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
def __init__(
self,
@@ -3301,131 +3300,8 @@ class BatchImportHandler:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
return self._windows_drives_response()
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return web.json_response(
{"success": False, "error": "Access denied to this directory"},
status=403,
)
if not path.exists():
return web.json_response(
{"success": False, "error": "Directory does not exist"},
status=404,
)
if not path.is_dir():
return web.json_response(
{"success": False, "error": "Path is not a directory"},
status=400,
)
# List directory contents
directories = []
image_files = []
image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in image_extensions:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
# Sort directories and files alphabetically
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = (
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
)
else:
parent_path = str(path.parent)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"},
@@ -3434,30 +3310,3 @@ class BatchImportHandler:
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _windows_drives_response(self) -> web.Response:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [
{"name": drive, "path": drive, "is_parent": False} for drive in drives
]
return web.json_response(
{
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
)
+2
View File
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
+2 -2
View File
@@ -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
+35 -3
View File
@@ -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,
)
+23
View File
@@ -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.<key>`` 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.
+152
View File
@@ -0,0 +1,152 @@
"""Shared directory-browsing logic for HTTP directory pickers."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Tuple
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
_IMAGE_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
"""Browse a directory and return (payload, http_status).
The payload shape matches the JSON responses historically produced by
``BatchImportHandler.browse_directory``: on success a dict with
``success``, ``current_path``, ``parent_path``, ``directories``,
``image_files``, ``image_count`` and ``directory_count``; on failure a
``{"success": False, "error": ...}`` dict with a 400/403/404/500 status.
"""
if os.name == "nt" and directory_path == WINDOWS_DRIVES_TOKEN:
return _windows_drives_payload(), 200
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return {"success": False, "error": "Access denied to this directory"}, 403
if not path.exists():
return {"success": False, "error": "Directory does not exist"}, 404
if not path.is_dir():
return {"success": False, "error": "Path is not a directory"}, 400
directories = []
image_files = []
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in _IMAGE_EXTENSIONS:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
else:
parent_path = str(path.parent)
return (
{
"success": True,
"current_path": str(path),
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
},
200,
)
except PermissionError:
return {"success": False, "error": "Permission denied"}, 403
except OSError as exc:
return {"success": False, "error": f"Error reading directory: {str(exc)}"}, 500
def _windows_drives_payload() -> Dict[str, Any]:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [{"name": drive, "path": drive, "is_parent": False} for drive in drives]
return {
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
+42
View File
@@ -118,6 +118,44 @@
transform: translateY(-1px);
}
/* Banner Pager (cycles through multiple active banners) */
.banner-pager {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
margin-left: var(--space-2);
}
.banner-pager-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
font-size: 0.75em;
padding: 0;
}
.banner-pager-btn:hover {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
.banner-pager-indicator {
font-size: 0.8em;
color: var(--text-muted);
min-width: 2.8em;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* Dismiss Button */
.banner-dismiss {
position: absolute;
@@ -183,6 +221,10 @@
flex-wrap: wrap;
justify-content: flex-start;
}
.banner-pager {
margin-left: 0;
}
.banner-action {
flex: 1;
@@ -0,0 +1,179 @@
/* Directory Picker Modal */
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
#directoryPickerModal {
z-index: 10010;
}
.directory-picker-content {
max-width: 560px;
display: flex;
flex-direction: column;
}
.directory-picker-content h3 {
color: var(--text-color);
margin-bottom: var(--space-2);
}
/* Manual path row */
#directoryPickerModal .directory-picker-path-row {
display: flex;
gap: 8px;
margin-bottom: var(--space-2);
}
#directoryPickerModal .directory-picker-path-row input {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
}
#directoryPickerModal .directory-picker-path-row input:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Directory browser (class names shared with the batch import browser) */
#directoryPickerModal .directory-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
#directoryPickerModal .browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
#directoryPickerModal .back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: var(--transition-base);
}
#directoryPickerModal .back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
#directoryPickerModal .back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#directoryPickerModal .current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#directoryPickerModal .browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
#directoryPickerModal .folder-list {
display: flex;
flex-direction: column;
gap: 4px;
}
#directoryPickerModal .folder-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: var(--transition-base);
border: 1px solid transparent;
}
#directoryPickerModal .folder-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
#directoryPickerModal .folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
#directoryPickerModal .item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#directoryPickerModal .browser-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
#directoryPickerModal .directory-picker-error {
margin-top: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
background: oklch(from var(--lora-error) l c h / 0.12);
color: var(--lora-error);
font-size: 0.85em;
word-break: break-word;
}
#directoryPickerModal .directory-picker-empty {
padding: var(--space-2);
text-align: center;
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
}
/* Dark theme adjustments */
[data-theme="dark"] #directoryPickerModal .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] #directoryPickerModal .browser-header,
[data-theme="dark"] #directoryPickerModal .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] #directoryPickerModal .folder-item i {
color: #fcd34d;
}
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
color: white;
}
/* Browse (directory picker) button boxed accent style used on the dynamic
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
path fields use the .inset variant below instead. */
#settingsModal .browse-path-btn {
width: 32px;
height: 32px;
padding: 0;
border-radius: var(--border-radius-xs);
border: 1px solid var(--lora-accent);
background: transparent;
color: var(--lora-accent);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
flex-shrink: 0;
}
#settingsModal .browse-path-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Inset variant (static path fields): the button floats inside the right
edge of the input, so the setting row keeps its single-control look and
narrow columns never push it onto a second line. */
#settingsModal .browse-path-btn.inset {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-color);
opacity: 0.55;
}
#settingsModal .browse-path-btn.inset:hover {
background: transparent;
color: var(--lora-accent);
opacity: 1;
}
#settingsModal input.has-inset-browse {
padding-right: 34px;
}
/* Advisory path validation feedback (wraps below the input row) */
#settingsModal .text-input-wrapper,
#settingsModal .path-control {
flex-wrap: wrap;
}
#settingsModal .path-control > .text-input-wrapper {
flex: 1;
min-width: 0;
}
.path-validation {
display: none;
flex-basis: 100%;
width: 100%;
margin-top: 4px;
font-size: 0.8em;
line-height: 1.4;
color: var(--lora-error);
}
.path-validation.visible {
display: flex;
align-items: center;
gap: 6px;
}
.path-validation.valid {
color: var(--lora-success);
}
/* Highlight animation for setting items targeted from Doctor actions */
@keyframes settings-highlight-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
@@ -1780,3 +1861,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;
}
+1
View File
@@ -18,6 +18,7 @@
@import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css';
@import 'components/modal/download-modal.css';
@import 'components/modal/directory-picker-modal.css';
@import 'components/toast.css';
@import 'components/loading.css';
@import 'components/menu.css';
@@ -0,0 +1,206 @@
import { translate } from '../utils/i18nHelpers.js';
/**
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
* Self-managed (NOT registered with ModalManager): it stacks above the
* settings modal, so ModalManager's "close current modal on open" behavior
* would kill the modal underneath.
*/
class DirectoryPickerModal {
constructor() {
this.isOpen = false;
this.currentPath = '';
this.parentPath = null;
this.onSelect = null;
this.elements = {};
this._bindings = [];
}
open({ initialPath = '', onSelect } = {}) {
this._cacheElements();
if (!this.elements.modal) {
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
return;
}
this._unbindEvents();
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
this.currentPath = '';
this.parentPath = null;
this._clearError();
this.elements.folderList.innerHTML = '';
this.elements.currentPathEl.textContent = '';
this.elements.upBtn.disabled = true;
this.elements.pathInput.value = initialPath || '';
this._bindEvents();
document.body.classList.add('modal-open');
this.elements.modal.style.display = 'block';
this.isOpen = true;
// An empty path lets the server pick its default (user home).
this.loadDirectory(initialPath || '');
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
this._unbindEvents();
if (this.elements.modal) {
this.elements.modal.style.display = 'none';
}
this.onSelect = null;
// Keep body.modal-open: the settings modal underneath may still be open.
}
async loadDirectory(path) {
try {
const response = await fetch('/api/lm/browse-directory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
const data = await response.json();
if (data.success) {
this._clearError();
this._renderDirectory(data);
} else {
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
}
} catch (error) {
console.error('Error loading directory:', error);
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
}
}
_cacheElements() {
const modal = document.getElementById('directoryPickerModal');
this.elements = {
modal,
closeBtn: document.getElementById('directoryPickerCloseBtn'),
pathInput: document.getElementById('directoryPickerPathInput'),
goBtn: document.getElementById('directoryPickerGoBtn'),
upBtn: document.getElementById('directoryPickerUpBtn'),
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
folderList: document.getElementById('directoryPickerFolderList'),
errorEl: document.getElementById('directoryPickerError'),
selectBtn: document.getElementById('directoryPickerSelectBtn')
};
}
_bind(target, type, handler, options) {
target.addEventListener(type, handler, options);
this._bindings.push([target, type, handler, options]);
}
_bindEvents() {
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
this._bind(closeBtn, 'click', () => this.close());
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
this._bind(pathInput, 'keydown', (event) => {
if (event.key === 'Enter') {
this.loadDirectory(pathInput.value.trim());
}
});
this._bind(upBtn, 'click', () => {
// Server-provided parent_path: Windows paths cannot be derived client-side.
if (this.parentPath) {
this.loadDirectory(this.parentPath);
}
});
this._bind(selectBtn, 'click', () => this._selectCurrent());
// Capture phase + stopPropagation so an ESC here never reaches the
// settings modal's own ESC handler underneath.
this._bind(document, 'keydown', (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
this.close();
}
}, true);
// Backdrop click (the .modal element itself, not its content).
this._bind(modal, 'click', (event) => {
if (event.target === modal) {
this.close();
}
});
}
_unbindEvents() {
for (const [target, type, handler, options] of this._bindings) {
target.removeEventListener(type, handler, options);
}
this._bindings = [];
}
_renderDirectory(data) {
this.currentPath = data.current_path || '';
this.parentPath = data.parent_path || null;
this.elements.currentPathEl.textContent = this.currentPath;
this.elements.pathInput.value = this.currentPath;
this.elements.upBtn.disabled = !this.parentPath;
const folderList = this.elements.folderList;
folderList.innerHTML = '';
const directories = data.directories || [];
if (directories.length === 0) {
const empty = document.createElement('div');
empty.className = 'directory-picker-empty';
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
folderList.appendChild(empty);
return;
}
directories.forEach((entry) => {
folderList.appendChild(this._createFolderItem(entry));
});
}
// Each entry is { name, path, is_parent }; the server supplies the full
// child path, so navigation never joins path segments client-side.
_createFolderItem(entry) {
const item = document.createElement('div');
item.className = 'folder-item';
item.innerHTML = `
<i class="fas fa-folder"></i>
<span class="item-name">${this._escapeHtml(entry.name)}</span>
`;
item.addEventListener('click', () => {
this.loadDirectory(entry.path);
});
return item;
}
_selectCurrent() {
if (!this.currentPath) return;
if (this.onSelect) {
this.onSelect(this.currentPath);
}
this.close();
}
_showError(message) {
this.elements.errorEl.textContent = message;
this.elements.errorEl.style.display = 'block';
}
_clearError() {
this.elements.errorEl.textContent = '';
this.elements.errorEl.style.display = 'none';
}
_escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
export const directoryPickerModal = new DirectoryPickerModal();
export { DirectoryPickerModal };
+129 -33
View File
@@ -31,6 +31,9 @@ class BannerService {
this.banners = new Map();
this.container = null;
this.initialized = false;
// Only one banner is rendered at a time; this index selects which of
// the active (non-dismissed) banners is currently displayed.
this.currentBannerIndex = 0;
this.recentHistory = this.loadBannerHistory();
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
@@ -121,12 +124,22 @@ class BannerService {
*/
registerBanner(id, bannerConfig) {
this.banners.set(id, bannerConfig);
// If already initialized, render the banner immediately
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
this.renderBanner(bannerConfig);
this.updateContainerVisibility();
if (!this.initialized || !this.container || this.isBannerDismissed(id)) {
return;
}
// Preempt the currently displayed banner only when the new one has a
// strictly higher priority (i.e. sorts earlier).
const activeBanners = this.getSortedActiveBanners();
const displayedId = this.container.querySelector('.banner-item')
?.getAttribute('data-banner-id');
const newIndex = activeBanners.findIndex(banner => banner.id === id);
const displayedIndex = activeBanners.findIndex(banner => banner.id === displayedId);
if (displayedIndex === -1 || (newIndex !== -1 && newIndex < displayedIndex)) {
this.currentBannerIndex = Math.max(newIndex, 0);
}
this.renderCurrentBanner();
}
/**
@@ -164,11 +177,10 @@ class BannerService {
if (banner && typeof banner.onRemove === 'function') {
banner.onRemove(bannerElement);
}
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
this.updateContainerVisibility();
this.renderCurrentBanner();
}, 300);
}
@@ -193,28 +205,87 @@ class BannerService {
}
}
/**
* Get active (non-dismissed) banners sorted by priority, highest first
* @returns {Object[]}
*/
getSortedActiveBanners() {
return Array.from(this.banners.values())
.filter(banner => !this.isBannerDismissed(banner.id))
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Show all active (non-dismissed) banners
*/
async showActiveBanners() {
if (!this.container) return;
const activeBanners = Array.from(this.banners.values())
.filter(banner => !this.isBannerDismissed(banner.id))
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
activeBanners.forEach(banner => {
this.renderBanner(banner);
});
this.updateContainerVisibility();
this.currentBannerIndex = 0;
this.renderCurrentBanner();
}
/**
* Render a banner to the DOM
* @param {Object} banner - Banner configuration
* Render the currently selected banner into the container. Only one
* banner is visible at a time; a pager lets the user cycle through the
* remaining active banners.
*/
renderBanner(banner) {
renderCurrentBanner() {
if (!this.container) return;
const activeBanners = this.getSortedActiveBanners();
this.container.innerHTML = '';
if (activeBanners.length === 0) {
this.currentBannerIndex = 0;
this.updateContainerVisibility();
return;
}
if (this.currentBannerIndex >= activeBanners.length) {
this.currentBannerIndex = activeBanners.length - 1;
}
if (this.currentBannerIndex < 0) {
this.currentBannerIndex = 0;
}
// Record every active banner once so dismissed/cycled-away banners
// remain reachable through the notification center history.
activeBanners.forEach(banner => this.recordBannerAppearance(banner));
const banner = activeBanners[this.currentBannerIndex];
const bannerElement = this.buildBannerElement(banner, activeBanners.length);
this.container.appendChild(bannerElement);
this.updateContainerVisibility();
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement);
}
}
/**
* Advance the displayed banner by offset, wrapping around
* @param {number} offset - +1 for next, -1 for previous
*/
showAdjacentBanner(offset) {
const activeBanners = this.getSortedActiveBanners();
if (activeBanners.length < 2) return;
this.currentBannerIndex =
(this.currentBannerIndex + offset + activeBanners.length) % activeBanners.length;
this.renderCurrentBanner();
}
/**
* Build a banner DOM element
* @param {Object} banner - Banner configuration
* @param {number} totalCount - Total number of active banners
* @returns {HTMLElement}
*/
buildBannerElement(banner, totalCount) {
const bannerElement = document.createElement('div');
bannerElement.className = 'banner-item';
bannerElement.setAttribute('data-banner-id', banner.id);
@@ -230,11 +301,34 @@ class BannerService {
</a>`;
}).join('') : '';
const dismissButtonHtml = banner.dismissible ?
const dismissButtonHtml = banner.dismissible ?
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
<i class="fas fa-times"></i>
</button>` : '';
let pagerHtml = '';
if (totalCount > 1) {
const previousLabel = translate('banners.pager.previous', {}, 'Previous message');
const nextLabel = translate('banners.pager.next', {}, 'Next message');
const positionLabel = translate('banners.pager.position', {
current: this.currentBannerIndex + 1,
total: totalCount
}, `Message ${this.currentBannerIndex + 1} of ${totalCount}`);
pagerHtml = `
<div class="banner-pager">
<button type="button" class="banner-pager-btn" data-pager="prev"
aria-label="${previousLabel}" title="${previousLabel}">
<i class="fas fa-chevron-left"></i>
</button>
<span class="banner-pager-indicator" aria-label="${positionLabel}">${this.currentBannerIndex + 1} / ${totalCount}</span>
<button type="button" class="banner-pager-btn" data-pager="next"
aria-label="${nextLabel}" title="${nextLabel}">
<i class="fas fa-chevron-right"></i>
</button>
</div>`;
}
bannerElement.innerHTML = `
<div class="banner-content">
<div class="banner-text">
@@ -244,18 +338,19 @@ class BannerService {
<div class="banner-actions">
${actionsHtml}
</div>
${pagerHtml}
</div>
${dismissButtonHtml}
`;
this.container.appendChild(bannerElement);
bannerElement.querySelectorAll('.banner-pager-btn').forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
this.showAdjacentBanner(button.getAttribute('data-pager') === 'next' ? 1 : -1);
});
});
this.recordBannerAppearance(banner);
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement);
}
return bannerElement;
}
/**
@@ -458,17 +553,18 @@ class BannerService {
* @param {string} bannerId - Banner ID to remove
*/
removeBannerElement(bannerId) {
// Also remove from banners map
this.banners.delete(bannerId);
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) {
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
this.updateContainerVisibility();
this.renderCurrentBanner();
}, 300);
} else {
this.renderCurrentBanner();
}
// Also remove from banners map
this.banners.delete(bannerId);
}
prepareCommunitySupportBanner() {
+684 -3
View File
@@ -15,9 +15,27 @@ import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
const PATH_VALIDATION_ERROR_I18N = {
path_not_found: { key: 'settings.pathValidation.pathNotFound', fallback: 'Path does not exist' },
not_a_directory: { key: 'settings.pathValidation.notADirectory', fallback: 'Not a directory' },
not_readable: { key: 'settings.pathValidation.notReadable', fallback: 'Path is not readable' },
not_writable: { key: 'settings.pathValidation.notWritable', fallback: 'Path is not writable' },
};
// 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 +44,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 +98,7 @@ export class SettingsManager {
await this.applyLanguageSetting();
this.applyFrontendSettings();
this.setupModelPathsSection();
}
async applyLanguageSetting() {
@@ -276,6 +297,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 +497,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 +512,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 +1188,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) {
@@ -1175,6 +1206,24 @@ export class SettingsManager {
if (useNewLicenseIconsCheckbox) {
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
}
// Directory browse buttons + advisory path validation (idempotent,
// safe to call on every modal open).
this.attachPathField('recipesPath', {
onAfterSelect: () => this.saveInputSetting('recipesPath', 'recipes_path'),
});
this.attachPathField('exampleImagesPath', {
onAfterSelect: (pickedPath) => {
// Mirror ExampleImagesManager's blur-save flow.
window.exampleImagesManager?.updateDownloadButtonState?.(pickedPath.trim() !== '');
this.saveSetting('example_images_path', pickedPath)
.then(() => showToast('toast.exampleImages.pathUpdated', {}, 'success'))
.catch((error) => showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error'));
},
});
this.attachPathField('exampleImagesLocalRoot', {
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
});
}
loadDownloadBackendSettings() {
@@ -1783,6 +1832,11 @@ export class SettingsManager {
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onfocus="settingsManager.clearExtraFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="browse-path-btn"
onclick="settingsManager.browseForPathRow(this, '${modelType}')"
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
<i class="fas fa-folder-open"></i>
</button>
<button type="button" class="remove-path-btn"
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
@@ -1942,6 +1996,478 @@ 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 = `
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
${translate('settings.modelPaths.title', {}, 'Model Library Paths')}
<i class="fas fa-sync-alt restart-required-icon" title="${translate('settings.modelPaths.restartRequired', {}, 'Restart required for changes to take effect')}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
${translate('settings.modelPaths.description', {}, 'Root folders LoRA Manager scans for your models. Changes take effect after restarting the server.')}
</div>
</div>
<div class="model-paths-restart-notice" id="modelPathsRestartNotice">
<i class="fas fa-exclamation-triangle"></i>
<span>${translate('settings.modelPaths.pendingRestartNotice', {}, 'Path changes saved. Restart LoRA Manager for them to take effect.')}</span>
</div>
<div class="settings-subsection-header">
<h4>${translate('settings.modelPaths.coreTypes', {}, 'Core Model Types')}</h4>
</div>
<div id="modelPathsCoreTypes"></div>
<div class="settings-subsection-header">
<h4>${translate('settings.modelPaths.otherTypes', {}, 'Other Model Types')}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="modelPathsEnableOtherModels">
${translate('settings.folderSettings.enableOtherModels', {}, 'Enable Other Models Management')}
<i class="fas fa-info-circle info-icon" data-tooltip="${translate('settings.folderSettings.enableOtherModelsHelp', {}, '')}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="modelPathsEnableOtherModels" onchange="settingsManager.handleModelPathsEnableOtherModels()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item other-subtype-toggles" id="modelPathsSubTypeToggles">
<div class="setting-row">
<div class="setting-info">
<label>
${translate('settings.folderSettings.otherSubTypes', {}, 'Enabled Model Types')}
<i class="fas fa-info-circle info-icon" data-tooltip="${translate('settings.folderSettings.otherSubTypesHelp', {}, '')}"></i>
</label>
</div>
<div class="setting-control other-subtype-checkboxes">
${this._buildModelPathSubTypeCheckboxes()}
</div>
</div>
</div>
<div class="setting-item" id="modelPathsOtherEmpty">
<div class="input-help">
${translate('settings.modelPaths.otherTypesDisabledHint', {}, 'No other model types are enabled. Turn on the types you need above to configure their folders.')}
</div>
</div>
<div id="modelPathsOtherTypes"></div>
</div>
`;
// 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 `
<label class="other-subtype-checkbox">
<input type="checkbox" value="${subType}"
data-model-paths-subtype="${subType}"
onchange="settingsManager.handleModelPathsSubTypeToggles()">
<span>${label}</span>
</label>
`;
}).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 = `
<div class="setting-row">
<div class="setting-info">
<label>${label}</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addModelFolderPathRow('${entry.key}')">
<i class="fas fa-plus"></i>
<span>${translate('common.actions.add', {}, 'Add')}</span>
</button>
</div>
</div>
<div class="extra-folder-paths-container" id="modelFolderPaths-${entry.key}">
</div>
`;
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 = `
<div class="path-controls">
<input type="text" class="extra-folder-path-input"
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
onblur="settingsManager.updateModelFolderPaths('${key}')"
onfocus="settingsManager.clearModelFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="browse-path-btn"
onclick="settingsManager.browseForPathRow(this, '${key}', true)"
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
<i class="fas fa-folder-open"></i>
</button>
<button type="button" class="remove-path-btn"
onclick="settingsManager.removeModelFolderPathRow(this, '${key}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="extra-folder-path-error"></div>
`;
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);
// The "model folders need setup" startup banner is obsolete once
// at least one folder path is configured.
const hasAnyPath = Object.values(folderPaths).some(value => {
const list = Array.isArray(value) ? value : [value];
return list.some(path => typeof path === 'string' && path.trim());
});
if (hasAnyPath) {
bannerService.removeBannerElement('startup-missing-model-paths');
}
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,
// Above startup warnings (60), below startup errors (90): a pending
// restart is the most actionable state and should preempt prompts.
priority: 80,
});
}
loadBaseModelMappings() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return;
@@ -3286,6 +3812,161 @@ export class SettingsManager {
}
}
// ── Directory picker + live path validation ─────────────────────────
// Validation is advisory only: it never blocks or alters save flows.
attachPathField(inputId, { expect = 'directory', onAfterSelect } = {}) {
const input = document.getElementById(inputId);
if (!input) {
console.warn(`SettingsManager.attachPathField: #${inputId} not found`);
return;
}
if (input.dataset.pathFieldAttached === '1') return;
input.dataset.pathFieldAttached = '1';
const browseBtn = document.createElement('button');
browseBtn.type = 'button';
browseBtn.className = 'browse-path-btn inset';
browseBtn.title = translate('settings.directoryPicker.title', {}, 'Browse Folders');
browseBtn.innerHTML = '<i class="fas fa-folder-open"></i>';
// Inset layout: the button is absolutely positioned inside the right
// edge of the input, so the row keeps its original single-control look.
const parent = input.parentElement;
let wrapper;
let statusHost;
if (parent && parent.classList.contains('path-control')) {
// e.g. #exampleImagesPath sits beside a Download button: wrap only
// the input so the button insets into it and Download stays beside it.
wrapper = document.createElement('div');
wrapper.className = 'text-input-wrapper';
parent.insertBefore(wrapper, input);
wrapper.appendChild(input);
statusHost = parent;
} else {
// .text-input-wrapper provided by the setting_input macro
wrapper = parent;
statusHost = parent;
}
wrapper.appendChild(browseBtn);
input.classList.add('has-inset-browse');
const statusEl = document.createElement('div');
statusEl.className = 'path-validation';
statusHost.appendChild(statusEl);
input._pathFieldConfig = { expect, onAfterSelect, statusEl };
browseBtn.addEventListener('click', () => this.browseForPath(inputId));
input.addEventListener('blur', () => {
clearTimeout(input._pathValidationTimer);
this.validatePath(input, statusEl, expect);
});
input.addEventListener('input', () => {
clearTimeout(input._pathValidationTimer);
input._pathValidationTimer = setTimeout(() => {
this.validatePath(input, statusEl, expect);
}, 500);
});
}
async validatePath(input, statusEl, expect = 'directory') {
const value = input.value.trim();
const seq = input._pathValidationSeq = (input._pathValidationSeq || 0) + 1;
if (!value) {
this._clearPathStatus(statusEl);
return;
}
let data;
try {
const response = await fetch('/api/lm/validate-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: value, expect }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
data = await response.json();
} catch (error) {
if (seq === input._pathValidationSeq) {
this._clearPathStatus(statusEl);
}
return;
}
// Ignore responses overtaken by newer input or validation runs.
if (seq !== input._pathValidationSeq || input.value.trim() !== value) {
return;
}
if (data.success && !data.error_code) {
statusEl.innerHTML = `<i class="fas fa-check-circle"></i><span>${translate('settings.pathValidation.valid', {}, 'Path is valid')}</span>`;
statusEl.classList.add('visible', 'valid');
} else {
const message = this._getPathValidationMessage(data);
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i><span></span>';
statusEl.querySelector('span').textContent = message;
statusEl.classList.add('visible');
statusEl.classList.remove('valid');
}
}
_getPathValidationMessage(data) {
const entry = PATH_VALIDATION_ERROR_I18N[data?.error_code];
if (entry) {
return translate(entry.key, {}, entry.fallback);
}
return data?.error || translate('settings.pathValidation.pathNotFound', {}, 'Path does not exist');
}
_clearPathStatus(statusEl) {
statusEl.classList.remove('visible', 'valid');
statusEl.textContent = '';
}
browseForPath(inputId, { onAfterSelect } = {}) {
const input = document.getElementById(inputId);
if (!input) return;
const config = input._pathFieldConfig || {};
const afterSelect = onAfterSelect || config.onAfterSelect;
directoryPickerModal.open({
initialPath: input.value.trim(),
onSelect: (pickedPath) => {
input.value = pickedPath;
if (config.statusEl) {
this.validatePath(input, config.statusEl, config.expect || 'directory');
}
if (typeof afterSelect === 'function') {
afterSelect(pickedPath);
}
},
});
}
// Browse variant for the dynamic extra/model folder path rows: picking a
// folder routes through the row's existing validation + save logic.
browseForPathRow(btn, key, isModelPath = false) {
const row = btn.closest('.extra-folder-path-row');
const input = row ? row.querySelector('.extra-folder-path-input') : null;
if (!input) return;
directoryPickerModal.open({
initialPath: input.value.trim(),
onSelect: (pickedPath) => {
input.value = pickedPath;
if (isModelPath) {
this.updateModelFolderPaths(key);
} else {
this.updateExtraFolderPaths(key);
}
},
});
}
async saveInputSetting(elementId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
+19 -6
View File
@@ -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);
+6
View File
@@ -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: [],
};
}
+16
View File
@@ -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);
}
+1
View File
@@ -14,3 +14,4 @@
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %}
{% include 'components/modals/directory_picker_modal.html' %}
@@ -0,0 +1,32 @@
<!-- Directory Picker Modal (self-managed by DirectoryPickerModal.js, stacked above the settings modal) -->
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
<div class="modal-content directory-picker-content">
<button class="close" id="directoryPickerCloseBtn">&times;</button>
<h3>{{ t('settings.directoryPicker.title') }}</h3>
<div class="directory-picker-path-row">
<input type="text" id="directoryPickerPathInput" placeholder="{{ t('settings.directoryPicker.pathPlaceholder') }}" autocomplete="off">
<button class="secondary-btn" id="directoryPickerGoBtn">
<i class="fas fa-arrow-right"></i> {{ t('settings.directoryPicker.go') }}
</button>
</div>
<div class="directory-browser" id="directoryPickerBrowser">
<div class="browser-header">
<button class="back-btn" id="directoryPickerUpBtn" title="{{ t('settings.directoryPicker.goUp') }}" disabled>
<i class="fas fa-arrow-up"></i>
</button>
<div class="current-path" id="directoryPickerCurrentPath"></div>
</div>
<div class="browser-content">
<div class="folder-list" id="directoryPickerFolderList"></div>
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
</div>
<div class="browser-footer">
<button class="primary-btn" id="directoryPickerSelectBtn">
<i class="fas fa-check"></i> {{ t('settings.directoryPicker.selectFolder') }}
</button>
</div>
</div>
</div>
</div>
+4 -20
View File
@@ -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 @@
<h2>{{ t('other.noPaths.title') }}</h2>
{% if standalone_mode %}
<p>{{ t('other.noPaths.descriptionStandalone') }}</p>
<p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p>
<button id="openModelPathsSettingsBtn" type="button">
<i class="fas fa-cog"></i> {{ t('other.noPaths.openModelPaths') }}
</button>
{% if settings_file %}
<p class="other-settings-file"><i class="fas fa-file-alt"></i> <code>{{ settings_file }}</code></p>
{% endif %}
<pre class="other-no-paths-config"><code>"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"]
}</code></pre>
<p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p>
<button id="openSettingsFolderBtn" type="button">
<i class="fas fa-folder-open"></i> {{ t('other.noPaths.openSettingsFolder') }}
</button>
@@ -0,0 +1,257 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params = {}, fallback = null) => fallback ?? key,
}));
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
function buildModalDom() {
document.body.innerHTML = `
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
<div class="modal-content directory-picker-content">
<button class="close" id="directoryPickerCloseBtn">&times;</button>
<h3>Select folder</h3>
<div class="directory-picker-path-row">
<input type="text" id="directoryPickerPathInput">
<button id="directoryPickerGoBtn">Go</button>
</div>
<div class="directory-browser" id="directoryPickerBrowser">
<div class="browser-header">
<button class="back-btn" id="directoryPickerUpBtn"></button>
<div class="current-path" id="directoryPickerCurrentPath"></div>
</div>
<div class="browser-content">
<div class="folder-list" id="directoryPickerFolderList"></div>
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
</div>
<div class="browser-footer">
<button class="primary-btn" id="directoryPickerSelectBtn">Select</button>
</div>
</div>
</div>
</div>`;
}
function okResponse(payload) {
return {
ok: true,
status: 200,
json: async () => ({ success: true, ...payload }),
};
}
describe('DirectoryPickerModal', () => {
let fetchMock;
beforeEach(() => {
vi.clearAllMocks();
buildModalDom();
document.body.classList.remove('modal-open');
fetchMock = vi.fn(async () => okResponse({
current_path: '/home/user',
parent_path: '/home',
directories: [
{ name: 'photos', path: '/home/user/photos', is_parent: false },
{ name: 'models', path: '/home/user/models', is_parent: false },
],
}));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
directoryPickerModal.close();
vi.unstubAllGlobals();
});
function lastRequestBody() {
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
}
function modalEl() {
return document.getElementById('directoryPickerModal');
}
it('open() loads the initial path via POST /api/lm/browse-directory', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/lm/browse-directory');
expect(options.method).toBe('POST');
expect(lastRequestBody().path).toBe('/home/user');
expect(modalEl().style.display).toBe('block');
expect(document.body.classList.contains('modal-open')).toBe(true);
});
it('renders the folder list and current path', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
});
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
const names = [...document.querySelectorAll('#directoryPickerFolderList .item-name')].map((el) => el.textContent);
expect(names).toEqual(['photos', 'models']);
});
it('drills down on folder click using the server-provided entry path', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
});
fetchMock.mockClear();
document.querySelectorAll('#directoryPickerFolderList .folder-item')[0].click();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('/home/user/photos');
});
it('drills down from a Windows path using the server-provided entry path', async () => {
fetchMock.mockImplementation(async () => okResponse({
current_path: 'C:\\Users\\miao',
parent_path: 'C:\\Users',
directories: [
{ name: 'models', path: 'C:\\Users\\miao\\models', is_parent: false },
],
}));
directoryPickerModal.open({ initialPath: 'C:\\Users\\miao', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(1);
});
fetchMock.mockClear();
document.querySelector('#directoryPickerFolderList .folder-item').click();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('C:\\Users\\miao\\models');
});
it('navigates up via the server-provided parent_path', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
});
fetchMock.mockClear();
document.getElementById('directoryPickerUpBtn').click();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('/home');
});
it('disables the Up button when parent_path is null', async () => {
fetchMock.mockImplementation(async () => okResponse({
current_path: '/',
parent_path: null,
directories: [],
}));
directoryPickerModal.open({ initialPath: '/', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/');
});
const upBtn = document.getElementById('directoryPickerUpBtn');
expect(upBtn.disabled).toBe(true);
fetchMock.mockClear();
upBtn.click();
expect(fetchMock).not.toHaveBeenCalled();
});
it('shows an empty-folder message for a directory without subfolders', async () => {
fetchMock.mockImplementation(async () => okResponse({
current_path: '/home/user/empty',
parent_path: '/home/user',
directories: [],
}));
directoryPickerModal.open({ initialPath: '/home/user/empty', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.querySelector('#directoryPickerFolderList .directory-picker-empty')).not.toBeNull();
});
});
it('calls onSelect with current_path and closes on Select', async () => {
const onSelect = vi.fn();
directoryPickerModal.open({ initialPath: '/home/user', onSelect });
await vi.waitFor(() => {
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
});
document.getElementById('directoryPickerSelectBtn').click();
expect(onSelect).toHaveBeenCalledWith('/home/user');
expect(modalEl().style.display).toBe('none');
});
it('shows the backend error message and keeps the previous listing', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => {
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
});
fetchMock.mockImplementation(async () => ({
ok: false,
status: 404,
json: async () => ({ success: false, error: 'Directory not found' }),
}));
await directoryPickerModal.loadDirectory('/gone');
const errorEl = document.getElementById('directoryPickerError');
expect(errorEl.textContent).toBe('Directory not found');
expect(errorEl.style.display).toBe('block');
expect(document.querySelectorAll('#directoryPickerFolderList .folder-item')).toHaveLength(2);
expect(document.getElementById('directoryPickerCurrentPath').textContent).toBe('/home/user');
});
it('closes on ESC and stops propagation to modals underneath', async () => {
const underlyingEscSpy = vi.fn();
document.addEventListener('keydown', underlyingEscSpy);
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
document.getElementById('directoryPickerPathInput').dispatchEvent(event);
expect(modalEl().style.display).toBe('none');
expect(underlyingEscSpy).not.toHaveBeenCalled();
// The settings modal's body lock must survive the picker closing.
expect(document.body.classList.contains('modal-open')).toBe(true);
document.removeEventListener('keydown', underlyingEscSpy);
});
it('loads the typed path on Go click and on Enter', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
fetchMock.mockClear();
const input = document.getElementById('directoryPickerPathInput');
input.value = '/var/models';
document.getElementById('directoryPickerGoBtn').click();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('/var/models');
fetchMock.mockClear();
input.value = '/tmp/other';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('/tmp/other');
});
it('closes on backdrop click but not on content click', async () => {
directoryPickerModal.open({ initialPath: '/home/user', onSelect: vi.fn() });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
modalEl().querySelector('.directory-picker-content').click();
expect(modalEl().style.display).toBe('block');
modalEl().click();
expect(modalEl().style.display).toBe('none');
});
});
@@ -43,6 +43,7 @@ describe('BannerService', () => {
// Reset banner service state
bannerService.banners.clear();
bannerService.initialized = false;
bannerService.currentBannerIndex = 0;
bannerService.recentHistory = []; // Clear history for each test
// Clear DOM
@@ -331,6 +332,116 @@ describe('BannerService', () => {
});
});
describe('Banner Rotation', () => {
const registerTestBanner = (id, priority) => {
bannerService.registerBanner(id, {
id,
title: `Banner ${id}`,
content: `Content ${id}`,
dismissible: true,
priority
});
};
const displayedBannerId = () =>
document.querySelector('#banner-container .banner-item')
?.getAttribute('data-banner-id');
let dismissedStore;
beforeEach(() => {
dismissedStore = [];
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return dismissedStore;
}
return defaultValue;
});
storageHelpers.setStorageItem.mockImplementation((key, value) => {
if (key === 'dismissed_banners') {
dismissedStore = value;
}
});
bannerService.container = document.getElementById('banner-container');
bannerService.initialized = true;
});
it('renders only the highest priority banner when multiple are active', () => {
registerTestBanner('low', 1);
registerTestBanner('high', 10);
const rendered = document.querySelectorAll('#banner-container .banner-item');
expect(rendered).toHaveLength(1);
expect(displayedBannerId()).toBe('high');
});
it('shows a pager with position indicator when multiple banners are active', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
const pager = document.querySelector('.banner-pager');
expect(pager).not.toBeNull();
expect(pager.querySelector('.banner-pager-indicator').textContent.trim())
.toBe('1 / 2');
});
it('does not show a pager for a single banner', () => {
registerTestBanner('only', 1);
expect(document.querySelector('.banner-pager')).toBeNull();
});
it('cycles to the next banner and wraps around', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
document.querySelector('[data-pager="next"]')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(displayedBannerId()).toBe('a');
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
.toBe('2 / 2');
document.querySelector('[data-pager="next"]')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(displayedBannerId()).toBe('b');
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
.toBe('1 / 2');
});
it('cycles backwards with the previous button', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
document.querySelector('[data-pager="prev"]')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(displayedBannerId()).toBe('a');
});
it('shows the next banner after the displayed one is dismissed', async () => {
vi.useFakeTimers();
try {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
expect(displayedBannerId()).toBe('b');
await bannerService.dismissBanner('b');
vi.advanceTimersByTime(300);
expect(displayedBannerId()).toBe('a');
} finally {
vi.useRealTimers();
}
});
it('records all active banners in history, not just the displayed one', () => {
registerTestBanner('a', 1);
registerTestBanner('b', 2);
const historyIds = bannerService.recentHistory.map(entry => entry.id);
expect(historyIds).toEqual(expect.arrayContaining(['a', 'b']));
});
});
describe('Banner History', () => {
const testBanner = {
id: 'test-banner',
@@ -0,0 +1,489 @@
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 = `
<nav class="settings-nav">
<ul class="settings-nav-list">
<li class="settings-nav-group">
<button type="button" class="settings-nav-item active" data-section="general">General</button>
</li>
</ul>
</nav>
<div class="settings-form">
<div class="settings-section active" id="section-general" data-section="general"></div>
</div>
`;
};
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);
});
it('gives the restart banner a higher priority than startup warnings', async () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
manager.addModelFolderPathRow('loras');
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
await manager.updateModelFolderPaths('loras');
const restartBanner = Array.from(bannerService.banners.values())
.find((banner) => banner.id.startsWith('model-paths-restart-'));
// Startup warnings map to 60; the restart cue must outrank them so it
// preempts the "model folders need setup" prompt in the banner pager.
expect(restartBanner.priority).toBeGreaterThan(60);
});
it('removes the "model folders need setup" startup banner once a path is saved', async () => {
buildModalDom();
setStandaloneSettings();
bannerService.registerBanner('startup-missing-model-paths', {
id: 'startup-missing-model-paths',
title: 'Model folders need setup',
content: 'stub',
dismissible: false,
priority: 60,
});
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
manager.addModelFolderPathRow('loras');
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
await manager.updateModelFolderPaths('loras');
expect(bannerService.banners.has('startup-missing-model-paths')).toBe(false);
});
it('keeps the setup banner when the saved paths are all empty', async () => {
buildModalDom();
setStandaloneSettings({ folder_paths: { loras: ['/data/loras'] } });
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
bannerService.registerBanner('startup-missing-model-paths', {
id: 'startup-missing-model-paths',
title: 'Model folders need setup',
content: 'stub',
dismissible: false,
priority: 60,
});
// Clear every row and save: an all-empty path set must not retire the
// setup prompt.
document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-input')
.forEach((input) => { input.value = ''; });
await manager.updateModelFolderPaths('loras');
expect(manager.saveSetting).toHaveBeenCalled();
expect(bannerService.banners.has('startup-missing-model-paths')).toBe(true);
});
});
@@ -0,0 +1,433 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
global: {
settings: {},
},
loadingManager: {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
},
},
createDefaultSettings: () => ({
language: 'en',
}),
}));
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(),
}));
vi.mock('../../../static/js/components/DirectoryPickerModal.js', () => ({
directoryPickerModal: {
open: vi.fn(),
close: vi.fn(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { directoryPickerModal } from '../../../static/js/components/DirectoryPickerModal.js';
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 appendPathInput = (id = 'recipesPath') => {
const wrapper = document.createElement('div');
wrapper.className = 'text-input-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.id = id;
wrapper.appendChild(input);
document.body.appendChild(wrapper);
return input;
};
const validResponse = (path) => ({
ok: true,
json: async () => ({
success: true,
path,
exists: true,
is_directory: true,
readable: true,
writable: true,
error_code: null,
}),
});
const invalidResponse = (errorCode) => ({
ok: true,
json: async () => ({
success: true,
path: '/missing',
exists: false,
is_directory: false,
readable: false,
writable: false,
error_code: errorCode,
error: `server: ${errorCode}`,
}),
});
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
});
describe('SettingsManager.attachPathField', () => {
it('keeps the input in its wrapper and injects an inset browse button and a status element', () => {
const manager = createManager();
const input = appendPathInput();
manager.attachPathField('recipesPath');
const wrapper = input.parentElement;
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
expect(browseBtn).not.toBeNull();
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
expect(input.classList.contains('has-inset-browse')).toBe(true);
expect(wrapper.querySelector('.path-validation')).not.toBeNull();
});
it('is idempotent — a second call does not duplicate the button', () => {
const manager = createManager();
const input = appendPathInput();
manager.attachPathField('recipesPath');
manager.attachPathField('recipesPath');
expect(document.querySelectorAll('.browse-path-btn')).toHaveLength(1);
expect(document.querySelectorAll('.path-validation')).toHaveLength(1);
expect(input.dataset.pathFieldAttached).toBe('1');
});
it('wraps only the input for insetting when inside .path-control, leaving siblings in place', () => {
const manager = createManager();
const container = document.createElement('div');
container.className = 'setting-control path-control';
const input = document.createElement('input');
input.type = 'text';
input.id = 'exampleImagesPath';
const downloadBtn = document.createElement('button');
downloadBtn.id = 'exampleImagesDownloadBtn';
container.appendChild(input);
container.appendChild(downloadBtn);
document.body.appendChild(container);
manager.attachPathField('exampleImagesPath');
const wrapper = input.parentElement;
expect(wrapper.classList.contains('text-input-wrapper')).toBe(true);
expect(wrapper.parentElement).toBe(container);
const browseBtn = wrapper.querySelector('.browse-path-btn.inset');
expect(browseBtn).not.toBeNull();
expect(wrapper.nextElementSibling).toBe(downloadBtn);
expect(container.querySelector('.path-validation')).not.toBeNull();
});
it('warns and no-ops when the input is missing', () => {
const manager = createManager();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
manager.attachPathField('doesNotExist');
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('SettingsManager.validatePath', () => {
it('posts to /api/lm/validate-path on blur with expect directory', async () => {
const manager = createManager();
const input = appendPathInput();
input.value = '/data/recipes';
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
manager.attachPathField('recipesPath');
input.dispatchEvent(new Event('blur'));
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
const [url, options] = global.fetch.mock.calls[0];
expect(url).toBe('/api/lm/validate-path');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ path: '/data/recipes', expect: 'directory' });
});
it('debounces rapid input events into a single validation call', async () => {
vi.useFakeTimers();
const manager = createManager();
const input = appendPathInput();
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
manager.attachPathField('recipesPath');
input.value = '/d';
input.dispatchEvent(new Event('input'));
input.value = '/da';
input.dispatchEvent(new Event('input'));
input.value = '/data';
input.dispatchEvent(new Event('input'));
await vi.advanceTimersByTimeAsync(499);
expect(global.fetch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('renders a valid status for a valid path', async () => {
const manager = createManager();
const input = appendPathInput();
input.value = '/data/recipes';
global.fetch = vi.fn().mockResolvedValue(validResponse('/data/recipes'));
manager.attachPathField('recipesPath');
input.dispatchEvent(new Event('blur'));
await vi.waitFor(() => {
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
});
const statusEl = document.querySelector('.path-validation');
expect(statusEl.classList.contains('valid')).toBe(true);
expect(statusEl.textContent).toContain('Path is valid');
expect(statusEl.querySelector('i.fas.fa-check-circle')).not.toBeNull();
});
it('renders an error status mapped from error_code', async () => {
const manager = createManager();
const input = appendPathInput();
input.value = '/missing';
global.fetch = vi.fn().mockResolvedValue(invalidResponse('path_not_found'));
manager.attachPathField('recipesPath');
input.dispatchEvent(new Event('blur'));
await vi.waitFor(() => {
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
});
const statusEl = document.querySelector('.path-validation');
expect(statusEl.classList.contains('valid')).toBe(false);
expect(statusEl.textContent).toContain('Path does not exist');
});
it('ignores stale responses overtaken by a newer value', async () => {
const manager = createManager();
const input = appendPathInput();
const deferreds = [];
global.fetch = vi.fn().mockImplementation(() => new Promise((resolve) => {
deferreds.push(resolve);
}));
manager.attachPathField('recipesPath');
input.value = '/old-path';
input.dispatchEvent(new Event('blur'));
input.value = '/new-path';
input.dispatchEvent(new Event('blur'));
expect(global.fetch).toHaveBeenCalledTimes(2);
// Newer request resolves first and renders valid status.
deferreds[1](validResponse('/new-path'));
await vi.waitFor(() => {
expect(document.querySelector('.path-validation').classList.contains('valid')).toBe(true);
});
// Older request resolves late and must not overwrite the status.
deferreds[0](invalidResponse('path_not_found'));
await Promise.resolve();
await Promise.resolve();
const statusEl = document.querySelector('.path-validation');
expect(statusEl.classList.contains('valid')).toBe(true);
expect(statusEl.textContent).toContain('Path is valid');
});
it('clears the status and skips fetch when the value is empty', async () => {
const manager = createManager();
const input = appendPathInput();
input.value = '/data';
global.fetch = vi.fn().mockResolvedValue(validResponse('/data'));
manager.attachPathField('recipesPath');
input.dispatchEvent(new Event('blur'));
await vi.waitFor(() => {
expect(document.querySelector('.path-validation').classList.contains('visible')).toBe(true);
});
global.fetch.mockClear();
input.value = '';
input.dispatchEvent(new Event('blur'));
await Promise.resolve();
const statusEl = document.querySelector('.path-validation');
expect(global.fetch).not.toHaveBeenCalled();
expect(statusEl.classList.contains('visible')).toBe(false);
expect(statusEl.textContent).toBe('');
});
it('clears the status silently on network failure', async () => {
const manager = createManager();
const input = appendPathInput();
input.value = '/data';
global.fetch = vi.fn().mockRejectedValue(new Error('network down'));
manager.attachPathField('recipesPath');
input.dispatchEvent(new Event('blur'));
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
await Promise.resolve();
await Promise.resolve();
const statusEl = document.querySelector('.path-validation');
expect(statusEl.classList.contains('visible')).toBe(false);
});
});
describe('SettingsManager.browseForPath', () => {
it('opens the picker with the current value and applies the selection', async () => {
const manager = createManager();
const input = appendPathInput();
input.value = '/initial';
const onAfterSelect = vi.fn();
global.fetch = vi.fn().mockResolvedValue(validResponse('/picked'));
manager.attachPathField('recipesPath', { onAfterSelect });
manager.browseForPath('recipesPath');
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
const openArgs = directoryPickerModal.open.mock.calls[0][0];
expect(openArgs.initialPath).toBe('/initial');
openArgs.onSelect('/picked');
expect(input.value).toBe('/picked');
expect(onAfterSelect).toHaveBeenCalledWith('/picked');
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
path: '/picked',
expect: 'directory',
});
});
});
describe('SettingsManager dynamic path rows', () => {
const appendExtraFolderContainer = (modelType = 'loras') => {
const container = document.createElement('div');
container.id = `extraFolderPaths-${modelType}`;
document.body.appendChild(container);
return container;
};
it('renders a browse button in extra folder path rows', () => {
const manager = createManager();
appendExtraFolderContainer('loras');
manager.addExtraFolderPathRow('loras', '/models/loras', false);
const row = document.querySelector('.extra-folder-path-row');
const browseBtn = row.querySelector('.browse-path-btn');
expect(browseBtn).not.toBeNull();
expect(browseBtn.querySelector('i.fas.fa-folder-open')).not.toBeNull();
// Browse button sits before the remove button.
expect(browseBtn.nextElementSibling.classList.contains('remove-path-btn')).toBe(true);
});
it('picker selection routes through updateExtraFolderPaths', () => {
const manager = createManager();
appendExtraFolderContainer('loras');
const updateSpy = vi
.spyOn(manager, 'updateExtraFolderPaths')
.mockResolvedValue();
manager.addExtraFolderPathRow('loras', '/models/loras', false);
const row = document.querySelector('.extra-folder-path-row');
const input = row.querySelector('.extra-folder-path-input');
const browseBtn = row.querySelector('.browse-path-btn');
manager.browseForPathRow(browseBtn, 'loras');
expect(directoryPickerModal.open).toHaveBeenCalledTimes(1);
const openArgs = directoryPickerModal.open.mock.calls[0][0];
expect(openArgs.initialPath).toBe('/models/loras');
openArgs.onSelect('/picked/loras');
expect(input.value).toBe('/picked/loras');
expect(updateSpy).toHaveBeenCalledWith('loras');
});
it('model path rows route through updateModelFolderPaths', () => {
const manager = createManager();
const container = document.createElement('div');
container.id = 'modelFolderPaths-loras';
document.body.appendChild(container);
const updateSpy = vi
.spyOn(manager, 'updateModelFolderPaths')
.mockResolvedValue();
manager.addModelFolderPathRow('loras', '/models/loras', false);
const row = container.querySelector('.extra-folder-path-row');
const input = row.querySelector('.extra-folder-path-input');
const browseBtn = row.querySelector('.browse-path-btn');
expect(browseBtn).not.toBeNull();
manager.browseForPathRow(browseBtn, 'loras', true);
const openArgs = directoryPickerModal.open.mock.calls[0][0];
openArgs.onSelect('/picked/loras');
expect(input.value).toBe('/picked/loras');
expect(updateSpy).toHaveBeenCalledWith('loras');
});
});
@@ -25,6 +25,7 @@ describe('Other Models disabled page', () => {
document.body.innerHTML = [
'<button id="enableOtherModelsBtn"></button>',
'<button id="openOtherModelsSettingsBtn"></button>',
'<button id="openModelPathsSettingsBtn"></button>',
'<button id="openSettingsFolderBtn"></button>',
].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,
@@ -30,6 +30,7 @@
'language': 'en',
'llm_api_key_set': False,
'other_models_paths_available': False,
'standalone_mode': False,
'theme': 'dark',
}),
'success': True,
+183
View File
@@ -0,0 +1,183 @@
import json
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
from py.routes.handlers.misc_handlers import FileSystemHandler
def _make_handler() -> FileSystemHandler:
# browse_directory/validate_path never touch the settings service
return FileSystemHandler(settings_service=SimpleNamespace())
class _Request:
def __init__(self, body: dict) -> None:
self._body = body
async def json(self):
return self._body
async def _browse(handler: FileSystemHandler, path: str):
response = await handler.browse_directory(_Request({"path": path}))
return response, json.loads(response.text)
async def _validate(handler: FileSystemHandler, path: str, expect: str = "directory"):
response = await handler.validate_path(
_Request({"path": path, "expect": expect})
)
return response, json.loads(response.text)
@pytest.mark.asyncio
async def test_browse_directory_empty_path_defaults_to_home(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
response, payload = await _browse(_make_handler(), "")
assert response.status == 200
assert payload["success"] is True
assert payload["current_path"] == str(tmp_path)
@pytest.mark.asyncio
async def test_browse_directory_lists_subdirs_sorted_and_filters(tmp_path):
(tmp_path / "zeta").mkdir()
(tmp_path / "alpha").mkdir()
(tmp_path / ".hidden").mkdir()
(tmp_path / "node_modules").mkdir()
(tmp_path / "__pycache__").mkdir()
response, payload = await _browse(_make_handler(), str(tmp_path))
assert response.status == 200
assert payload["success"] is True
assert [d["name"] for d in payload["directories"]] == ["alpha", "zeta"]
assert payload["directory_count"] == 2
@pytest.mark.asyncio
async def test_browse_directory_missing_returns_404(tmp_path):
response, payload = await _browse(_make_handler(), str(tmp_path / "nope"))
assert response.status == 404
assert payload["success"] is False
@pytest.mark.asyncio
async def test_browse_directory_file_path_returns_400(tmp_path):
file_path = tmp_path / "file.txt"
file_path.write_text("x")
response, payload = await _browse(_make_handler(), str(file_path))
assert response.status == 400
assert payload["success"] is False
@pytest.mark.asyncio
async def test_browse_directory_relative_path_returns_403(monkeypatch):
# resolve() normally absolutizes relative paths against the cwd; bypass it
# to exercise the access-denied branch directly.
monkeypatch.setattr(Path, "resolve", lambda self: self)
response, payload = await _browse(_make_handler(), "relative/path")
assert response.status == 403
assert payload["success"] is False
@pytest.mark.asyncio
async def test_validate_path_existing_directory(tmp_path):
response, payload = await _validate(_make_handler(), str(tmp_path))
assert response.status == 200
assert payload == {
"success": True,
"path": os.path.abspath(str(tmp_path)),
"exists": True,
"is_directory": True,
"readable": True,
"writable": True,
"error_code": None,
}
@pytest.mark.asyncio
async def test_validate_path_not_found(tmp_path):
response, payload = await _validate(_make_handler(), str(tmp_path / "missing"))
assert response.status == 200
assert payload["success"] is True
assert payload["exists"] is False
assert payload["error_code"] == "path_not_found"
@pytest.mark.asyncio
async def test_validate_path_file_when_directory_expected(tmp_path):
file_path = tmp_path / "file.txt"
file_path.write_text("x")
response, payload = await _validate(_make_handler(), str(file_path))
assert response.status == 200
assert payload["error_code"] == "not_a_directory"
assert payload["exists"] is True
assert payload["is_directory"] is False
@pytest.mark.asyncio
async def test_validate_path_expect_file_on_file(tmp_path):
file_path = tmp_path / "file.txt"
file_path.write_text("x")
response, payload = await _validate(_make_handler(), str(file_path), expect="file")
assert response.status == 200
assert payload["error_code"] is None
assert payload["exists"] is True
assert payload["is_directory"] is False
@pytest.mark.skipif(
not hasattr(os, "geteuid") or os.geteuid() == 0,
reason="root bypasses permission checks",
)
@pytest.mark.asyncio
async def test_validate_path_unreadable_directory(tmp_path):
locked = tmp_path / "locked"
locked.mkdir()
locked.chmod(0o000)
try:
response, payload = await _validate(_make_handler(), str(locked))
finally:
locked.chmod(0o755)
assert response.status == 200
assert payload["error_code"] == "not_readable"
assert payload["readable"] is False
@pytest.mark.asyncio
async def test_validate_path_empty_path_returns_400():
response, payload = await _validate(_make_handler(), "")
assert response.status == 400
assert payload["success"] is False
@pytest.mark.asyncio
async def test_validate_path_expands_user(tmp_path, monkeypatch):
subdir = tmp_path / "subdir"
subdir.mkdir()
monkeypatch.setenv("HOME", str(tmp_path))
response, payload = await _validate(_make_handler(), "~/subdir")
assert response.status == 200
assert payload["error_code"] is None
assert payload["path"] == os.path.abspath(str(subdir))
+131
View File
@@ -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": [],
}
+17 -1
View File
@@ -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
+33 -1
View File
@@ -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)
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"]