mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-15 02:03:22 -03:00
Compare commits
22 Commits
b019326747
...
v1.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6a614e0d | |||
| feab01cd9c | |||
| 966024e534 | |||
| 2018722cc8 | |||
| 9d85c2a44a | |||
| 03dd047e62 | |||
| 86b547c1e0 | |||
| bab9752c8b | |||
| 774cc1be86 | |||
| 234b73c8a2 | |||
| abd06c48f4 | |||
| 6ca411e4e4 | |||
| 6470021e77 | |||
| 71658ab37b | |||
| 4f016a8024 | |||
| f362ed585b | |||
| 196172624f | |||
| 316702b7ab | |||
| a7625b009f | |||
| 5d4a33c90d | |||
| 041a6b8525 | |||
| 2638109ad6 |
+301
-285
File diff suppressed because it is too large
Load Diff
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert",
|
||||
"checkpointUnetOverlap": "Derselbe Pfad kann nicht für Checkpoints und Diffusionsmodelle verwendet werden: {paths}",
|
||||
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai-Daten aktualisieren",
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"relinkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"linkModel": "Modell verknüpfen",
|
||||
"linkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"linkHuggingFace": "Mit HuggingFace verknüpfen",
|
||||
"copySyntax": "LoRA-Syntax kopieren",
|
||||
"copyFilename": "Modell-Dateiname kopieren",
|
||||
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "Download wird vorbereitet...",
|
||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||
"finalizing": "Download wird abgeschlossen..."
|
||||
"finalizing": "Download wird abgeschlossen...",
|
||||
"cancelling": "Download wird abgebrochen...",
|
||||
"cancelled": "Download abgebrochen"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Aktuelle Datei:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
|
||||
"root": "Stammverzeichnis"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Mit HuggingFace verknüpfen",
|
||||
"infoText": "Fügen Sie die HuggingFace-Repository-URL ein, um dieses Modell zuzuordnen. Dies ermöglicht die KI-gestützte Metadatenanreicherung.",
|
||||
"urlLabel": "HuggingFace-Repository-URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
|
||||
"confirmAction": "Speichern & Verknüpfen"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Mit Civitai neu verknüpfen",
|
||||
"warning": "Warnung:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
||||
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
||||
"loadError": "Fehler beim Laden der Downloads: {message}",
|
||||
"downloadError": "Download-Fehler: {message}"
|
||||
"downloadError": "Download-Fehler: {message}",
|
||||
"downloadStopped": "Download abgebrochen"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "Fehler beim Setzen der Inhaltsbewertung: {message}",
|
||||
"relinkSuccess": "Modell erfolgreich mit Civitai neu verknüpft",
|
||||
"relinkFailed": "Fehler: {message}",
|
||||
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
|
||||
"linkHfFailed": "Fehler: {message}",
|
||||
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
||||
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
||||
"missingHash": "Modell-Hash nicht verfügbar"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
|
||||
"saveError": "Failed to update extra folder paths: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "This path is already configured"
|
||||
"duplicatePath": "This path is already configured",
|
||||
"checkpointUnetOverlap": "Cannot use the same path for both checkpoints and diffusion models: {paths}",
|
||||
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Refresh Civitai Data",
|
||||
"checkUpdates": "Check Updates",
|
||||
"relinkCivitai": "Re-link to Civitai",
|
||||
"linkModel": "Link Model",
|
||||
"linkCivitai": "Link to Civitai",
|
||||
"linkHuggingFace": "Link to HuggingFace",
|
||||
"copySyntax": "Copy LoRA Syntax",
|
||||
"copyFilename": "Copy Model Filename",
|
||||
"copyRecipeSyntax": "Copy Recipe Syntax",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "Preparing download...",
|
||||
"downloadedPreview": "Downloaded preview image",
|
||||
"downloadingFile": "Downloading {type} file",
|
||||
"finalizing": "Finalizing download..."
|
||||
"finalizing": "Finalizing download...",
|
||||
"cancelling": "Cancelling download...",
|
||||
"cancelled": "Download cancelled"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Current file:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "Type folder path or select from tree below...",
|
||||
"root": "Root"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Link to HuggingFace",
|
||||
"infoText": "Paste the HuggingFace repository URL to associate this model with its source. This enables AI-powered metadata enrichment.",
|
||||
"urlLabel": "HuggingFace Repository URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Enter the full URL of the HuggingFace repository.",
|
||||
"confirmAction": "Save & Link"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Re-link to Civitai",
|
||||
"warning": "Warning:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "Example images {action} completed",
|
||||
"imagesFailed": "Example images {action} failed",
|
||||
"loadError": "Error loading downloads: {message}",
|
||||
"downloadError": "Download error: {message}"
|
||||
"downloadError": "Download error: {message}",
|
||||
"downloadStopped": "Download cancelled"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Failed to load folder tree",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "Failed to set content rating: {message}",
|
||||
"relinkSuccess": "Model successfully re-linked to Civitai",
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Model successfully linked to HuggingFace",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
|
||||
"noCivitaiInfo": "No CivitAI information available",
|
||||
"missingHash": "Model hash not available"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Esta ruta ya está configurada"
|
||||
"duplicatePath": "Esta ruta ya está configurada",
|
||||
"checkpointUnetOverlap": "No se puede usar la misma ruta para checkpoints y modelos de difusión: {paths}",
|
||||
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualizar datos de Civitai",
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"relinkCivitai": "Re-vincular a Civitai",
|
||||
"linkModel": "Vincular modelo",
|
||||
"linkCivitai": "Re-vincular a Civitai",
|
||||
"linkHuggingFace": "Vincular a HuggingFace",
|
||||
"copySyntax": "Copiar sintaxis de LoRA",
|
||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "Preparando descarga...",
|
||||
"downloadedPreview": "Imagen de vista previa descargada",
|
||||
"downloadingFile": "Descargando archivo de {type}",
|
||||
"finalizing": "Finalizando descarga..."
|
||||
"finalizing": "Finalizando descarga...",
|
||||
"cancelling": "Cancelando descarga...",
|
||||
"cancelled": "Descarga cancelada"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Archivo actual:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
|
||||
"root": "Raíz"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Vincular a HuggingFace",
|
||||
"infoText": "Pegue la URL del repositorio de HuggingFace para asociar este modelo. Esto permite el enriquecimiento de metadatos con IA.",
|
||||
"urlLabel": "URL del repositorio de HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
|
||||
"confirmAction": "Guardar y vincular"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Re-vincular a Civitai",
|
||||
"warning": "Advertencia:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
||||
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
||||
"loadError": "Error al cargar descargas: {message}",
|
||||
"downloadError": "Error de descarga: {message}"
|
||||
"downloadError": "Error de descarga: {message}",
|
||||
"downloadStopped": "Descarga cancelada"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "Error al establecer clasificación de contenido: {message}",
|
||||
"relinkSuccess": "Modelo re-vinculado exitosamente a Civitai",
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
||||
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
||||
"missingHash": "Hash del modelo no disponible"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Ce chemin est déjà configuré"
|
||||
"duplicatePath": "Ce chemin est déjà configuré",
|
||||
"checkpointUnetOverlap": "Impossible d'utiliser le même chemin pour les checkpoints et les modèles de diffusion : {paths}",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualiser les données Civitai",
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"relinkCivitai": "Relier à nouveau à Civitai",
|
||||
"linkModel": "Lier le modèle",
|
||||
"linkCivitai": "Relier à nouveau à Civitai",
|
||||
"linkHuggingFace": "Lier à HuggingFace",
|
||||
"copySyntax": "Copier la syntaxe LoRA",
|
||||
"copyFilename": "Copier le nom de fichier du modèle",
|
||||
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "Préparation du téléchargement...",
|
||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||
"downloadingFile": "Téléchargement du fichier {type}",
|
||||
"finalizing": "Finalisation du téléchargement..."
|
||||
"finalizing": "Finalisation du téléchargement...",
|
||||
"cancelling": "Annulation du téléchargement...",
|
||||
"cancelled": "Téléchargement annulé"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Fichier actuel :",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
|
||||
"root": "Racine"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Lier à HuggingFace",
|
||||
"infoText": "Collez l'URL du dépôt HuggingFace pour associer ce modèle à sa source. Cela permet l'enrichissement des métadonnées par IA.",
|
||||
"urlLabel": "URL du dépôt HuggingFace :",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
|
||||
"confirmAction": "Enregistrer & lier"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Relier à nouveau à Civitai",
|
||||
"warning": "Attention :",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "Images d'exemple {action} terminées",
|
||||
"imagesFailed": "Images d'exemple {action} échouées",
|
||||
"loadError": "Erreur lors du chargement des téléchargements : {message}",
|
||||
"downloadError": "Erreur de téléchargement : {message}"
|
||||
"downloadError": "Erreur de téléchargement : {message}",
|
||||
"downloadStopped": "Téléchargement annulé"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "Échec de la définition de la classification du contenu : {message}",
|
||||
"relinkSuccess": "Modèle relié à Civitai avec succès",
|
||||
"relinkFailed": "Erreur : {message}",
|
||||
"linkHfSuccess": "Modèle lié à HuggingFace avec succès",
|
||||
"linkHfFailed": "Erreur : {message}",
|
||||
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
|
||||
"noCivitaiInfo": "Aucune information CivitAI disponible",
|
||||
"missingHash": "Hash du modèle non disponible"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
"duplicatePath": "נתיב זה כבר מוגדר",
|
||||
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
|
||||
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "רענן נתוני Civitai",
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"relinkCivitai": "קשר מחדש ל-Civitai",
|
||||
"linkModel": "קישור מודל",
|
||||
"linkCivitai": "קשר מחדש ל-Civitai",
|
||||
"linkHuggingFace": "קישור ל-HuggingFace",
|
||||
"copySyntax": "העתק תחביר LoRA",
|
||||
"copyFilename": "העתק שם קובץ מודל",
|
||||
"copyRecipeSyntax": "העתק תחביר מתכון",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "מכין הורדה...",
|
||||
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
||||
"downloadingFile": "מוריד קובץ {type}",
|
||||
"finalizing": "מסיים הורדה..."
|
||||
"finalizing": "מסיים הורדה...",
|
||||
"cancelling": "מבטל הורדה...",
|
||||
"cancelled": "ההורדה בוטלה"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "הקובץ הנוכחי:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
|
||||
"root": "שורש"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "קישור ל-HuggingFace",
|
||||
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-דאטה באמצעות AI.",
|
||||
"urlLabel": "כתובת URL של מאגר HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
|
||||
"confirmAction": "שמור וקשר"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "קשר מחדש ל-Civitai",
|
||||
"warning": "אזהרה:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
||||
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
||||
"loadError": "שגיאה בטעינת הורדות: {message}",
|
||||
"downloadError": "שגיאת הורדה: {message}"
|
||||
"downloadError": "שגיאת הורדה: {message}",
|
||||
"downloadStopped": "ההורדה בוטלה"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "הגדרת דירוג התוכן נכשלה: {message}",
|
||||
"relinkSuccess": "המודל קושר מחדש ל-Civitai בהצלחה",
|
||||
"relinkFailed": "שגיאה: {message}",
|
||||
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
|
||||
"linkHfFailed": "שגיאה: {message}",
|
||||
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||
"missingHash": "ה-hash של המודל אינו זמין"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
"duplicatePath": "このパスはすでに設定されています",
|
||||
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitaiデータを更新",
|
||||
"checkUpdates": "更新確認",
|
||||
"relinkCivitai": "Civitaiに再リンク",
|
||||
"linkModel": "モデルをリンク",
|
||||
"linkCivitai": "Civitai にリンク",
|
||||
"linkHuggingFace": "HuggingFace にリンク",
|
||||
"copySyntax": "LoRA構文をコピー",
|
||||
"copyFilename": "モデルファイル名をコピー",
|
||||
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "ダウンロードを準備中...",
|
||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||
"finalizing": "ダウンロードを完了中..."
|
||||
"finalizing": "ダウンロードを完了中...",
|
||||
"cancelling": "ダウンロードをキャンセル中...",
|
||||
"cancelled": "ダウンロードをキャンセルしました"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "現在のファイル:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
|
||||
"root": "ルート"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace にリンク",
|
||||
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
|
||||
"urlLabel": "HuggingFace リポジトリ URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
|
||||
"confirmAction": "保存&リンク"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitaiに再リンク",
|
||||
"warning": "警告:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "例画像 {action} が完了しました",
|
||||
"imagesFailed": "例画像 {action} が失敗しました",
|
||||
"loadError": "ダウンロード読み込みエラー:{message}",
|
||||
"downloadError": "ダウンロードエラー:{message}"
|
||||
"downloadError": "ダウンロードエラー:{message}",
|
||||
"downloadStopped": "ダウンロードをキャンセルしました"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
|
||||
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
|
||||
"relinkFailed": "エラー:{message}",
|
||||
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||
"linkHfFailed": "エラー:{message}",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다",
|
||||
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
|
||||
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"relinkCivitai": "Civitai에 다시 연결",
|
||||
"linkModel": "모델 연결",
|
||||
"linkCivitai": "Civitai에 연결",
|
||||
"linkHuggingFace": "HuggingFace에 연결",
|
||||
"copySyntax": "LoRA 문법 복사",
|
||||
"copyFilename": "모델 파일명 복사",
|
||||
"copyRecipeSyntax": "레시피 문법 복사",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "다운로드 준비 중...",
|
||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||
"downloadingFile": "{type} 파일 다운로드 중",
|
||||
"finalizing": "다운로드 완료 중..."
|
||||
"finalizing": "다운로드 완료 중...",
|
||||
"cancelling": "다운로드 취소 중...",
|
||||
"cancelled": "다운로드가 취소되었습니다"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "현재 파일:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
|
||||
"root": "루트"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace에 연결",
|
||||
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
|
||||
"urlLabel": "HuggingFace 저장소 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
|
||||
"confirmAction": "저장 및 연결"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitai에 다시 연결",
|
||||
"warning": "경고:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
||||
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
||||
"loadError": "다운로드 로딩 오류: {message}",
|
||||
"downloadError": "다운로드 오류: {message}"
|
||||
"downloadError": "다운로드 오류: {message}",
|
||||
"downloadStopped": "다운로드가 취소되었습니다"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
|
||||
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
|
||||
"relinkFailed": "오류: {message}",
|
||||
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||
"linkHfFailed": "오류: {message}",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
"duplicatePath": "Этот путь уже настроен",
|
||||
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
|
||||
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Обновить данные Civitai",
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"relinkCivitai": "Пересвязать с Civitai",
|
||||
"linkModel": "Связать модель",
|
||||
"linkCivitai": "Пересвязать с Civitai",
|
||||
"linkHuggingFace": "Связать с HuggingFace",
|
||||
"copySyntax": "Копировать синтаксис LoRA",
|
||||
"copyFilename": "Копировать имя файла модели",
|
||||
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "Подготовка загрузки...",
|
||||
"downloadedPreview": "Превью изображение загружено",
|
||||
"downloadingFile": "Загрузка файла {type}",
|
||||
"finalizing": "Завершение загрузки..."
|
||||
"finalizing": "Завершение загрузки...",
|
||||
"cancelling": "Отмена загрузки...",
|
||||
"cancelled": "Загрузка отменена"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Текущий файл:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
|
||||
"root": "Корень"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Связать с HuggingFace",
|
||||
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
|
||||
"urlLabel": "URL репозитория HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Введите полный URL репозитория HuggingFace.",
|
||||
"confirmAction": "Сохранить и связать"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Пересвязать с Civitai",
|
||||
"warning": "Предупреждение:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "Примеры изображений {action} завершены",
|
||||
"imagesFailed": "Примеры изображений {action} не удались",
|
||||
"loadError": "Ошибка загрузки downloads: {message}",
|
||||
"downloadError": "Ошибка загрузки: {message}"
|
||||
"downloadError": "Ошибка загрузки: {message}",
|
||||
"downloadStopped": "Загрузка отменена"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "Не удалось установить рейтинг контента: {message}",
|
||||
"relinkSuccess": "Модель успешно пересвязана с Civitai",
|
||||
"relinkFailed": "Ошибка: {message}",
|
||||
"linkHfSuccess": "Модель успешно связана с HuggingFace",
|
||||
"linkHfFailed": "Ошибка: {message}",
|
||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||
"missingHash": "Хеш модели недоступен"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
"duplicatePath": "此路径已配置",
|
||||
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
|
||||
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 数据",
|
||||
"checkUpdates": "检查更新",
|
||||
"relinkCivitai": "重新关联到 Civitai",
|
||||
"linkModel": "链接模型",
|
||||
"linkCivitai": "链接到 Civitai",
|
||||
"linkHuggingFace": "链接到 HuggingFace",
|
||||
"copySyntax": "复制 LoRA 语法",
|
||||
"copyFilename": "复制模型文件名",
|
||||
"copyRecipeSyntax": "复制配方语法",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "正在准备下载...",
|
||||
"downloadedPreview": "预览图片已下载",
|
||||
"downloadingFile": "正在下载 {type} 文件",
|
||||
"finalizing": "正在完成下载..."
|
||||
"finalizing": "正在完成下载...",
|
||||
"cancelling": "取消下载中...",
|
||||
"cancelled": "下载已取消"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "当前文件:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
|
||||
"root": "根目录"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "链接到 HuggingFace",
|
||||
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
|
||||
"urlLabel": "HuggingFace 仓库 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
|
||||
"confirmAction": "保存并链接"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新关联到 Civitai",
|
||||
"warning": "警告:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "示例图片{action}完成",
|
||||
"imagesFailed": "示例图片{action}失败",
|
||||
"loadError": "加载下载项出错:{message}",
|
||||
"downloadError": "下载错误:{message}"
|
||||
"downloadError": "下载错误:{message}",
|
||||
"downloadStopped": "下载已取消"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "加载文件夹树失败",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "设置内容评级失败:{message}",
|
||||
"relinkSuccess": "模型已成功重新关联到 Civitai",
|
||||
"relinkFailed": "错误:{message}",
|
||||
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||
"linkHfFailed": "错误:{message}",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
|
||||
+21
-4
@@ -505,7 +505,9 @@
|
||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
"duplicatePath": "此路徑已設定",
|
||||
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
|
||||
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -786,7 +788,9 @@
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 資料",
|
||||
"checkUpdates": "檢查更新",
|
||||
"relinkCivitai": "重新連結 Civitai",
|
||||
"linkModel": "連結模型",
|
||||
"linkCivitai": "連結到 Civitai",
|
||||
"linkHuggingFace": "連結到 HuggingFace",
|
||||
"copySyntax": "複製 LoRA 語法",
|
||||
"copyFilename": "複製模型檔名",
|
||||
"copyRecipeSyntax": "複製配方語法",
|
||||
@@ -1203,7 +1207,9 @@
|
||||
"preparing": "準備下載中...",
|
||||
"downloadedPreview": "已下載預覽圖片",
|
||||
"downloadingFile": "正在下載 {type} 檔案",
|
||||
"finalizing": "完成下載中..."
|
||||
"finalizing": "完成下載中...",
|
||||
"cancelling": "取消下載中...",
|
||||
"cancelled": "下載已取消"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "目前檔案:",
|
||||
@@ -1319,6 +1325,14 @@
|
||||
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
|
||||
"root": "根目錄"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "連結到 HuggingFace",
|
||||
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
|
||||
"urlLabel": "HuggingFace 倉庫 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
|
||||
"confirmAction": "儲存並連結"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新連結至 Civitai",
|
||||
"warning": "警告:",
|
||||
@@ -2003,7 +2017,8 @@
|
||||
"imagesCompleted": "範例圖片{action}完成",
|
||||
"imagesFailed": "範例圖片{action}失敗",
|
||||
"loadError": "載入下載時發生錯誤:{message}",
|
||||
"downloadError": "下載錯誤:{message}"
|
||||
"downloadError": "下載錯誤:{message}",
|
||||
"downloadStopped": "下載已取消"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||
@@ -2048,6 +2063,8 @@
|
||||
"contentRatingFailed": "設定內容分級失敗:{message}",
|
||||
"relinkSuccess": "模型已成功重新連結至 Civitai",
|
||||
"relinkFailed": "錯誤:{message}",
|
||||
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||
"linkHfFailed": "錯誤:{message}",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
|
||||
@@ -41,7 +41,12 @@ async def api_json_error(
|
||||
if exc.status < 400:
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
# Preview 404 is routine (file deleted from disk) — not worth a warning.
|
||||
logger_method = logger.warning
|
||||
if request.path.startswith("/api/lm/previews") and exc.status == 404:
|
||||
logger_method = logger.debug
|
||||
|
||||
logger_method(
|
||||
"API %s %s returned HTTP %d: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
|
||||
@@ -96,7 +96,7 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
||||
|
||||
|
||||
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str, folder: str = "") -> None:
|
||||
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
||||
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||
|
||||
Uses ``MetadataManager.create_default_metadata()`` which computes the
|
||||
@@ -105,11 +105,6 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str, folder:
|
||||
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
|
||||
register the model in the in-memory scanner cache so it appears
|
||||
immediately without a full filesystem walk.
|
||||
|
||||
Args:
|
||||
folder: Relative folder path within the model root. Passed by the
|
||||
caller rather than re-derived from file paths to avoid mismatches
|
||||
when ``dest_path`` was realpath-resolved but scanner roots are not.
|
||||
"""
|
||||
try:
|
||||
hf_url = f"https://huggingface.co/{repo}"
|
||||
@@ -135,22 +130,138 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str, folder:
|
||||
await MetadataManager.save_metadata(dest_path, metadata_dict)
|
||||
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||
|
||||
# 4. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||
# 4. Determine relative folder path for cache
|
||||
# model_root is an absolute path; dest_path is under it
|
||||
folder = ""
|
||||
if os.path.isabs(model_root) and dest_path.startswith(model_root):
|
||||
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
|
||||
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
||||
|
||||
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
scanner = await scanner_getter() if scanner_getter is not None else None
|
||||
if scanner is not None:
|
||||
metadata_dict = metadata.to_dict()
|
||||
metadata_dict["hf_url"] = hf_url
|
||||
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||
if scanner_getter is not None:
|
||||
scanner = await scanner_getter()
|
||||
if scanner is not None:
|
||||
metadata_dict = metadata.to_dict()
|
||||
metadata_dict["hf_url"] = hf_url
|
||||
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
|
||||
|
||||
|
||||
def _find_matching_root(dest_dir: str) -> str | None:
|
||||
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
|
||||
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
|
||||
all_roots = []
|
||||
for root_list in (
|
||||
config.loras_roots or [],
|
||||
config.extra_loras_roots or [],
|
||||
config.checkpoints_roots or [],
|
||||
config.extra_checkpoints_roots or [],
|
||||
config.unet_roots or [],
|
||||
config.extra_unet_roots or [],
|
||||
config.embeddings_roots or [],
|
||||
config.extra_embeddings_roots or [],
|
||||
):
|
||||
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
|
||||
# Find the longest matching prefix
|
||||
match: str | None = None
|
||||
for root in all_roots:
|
||||
if norm.startswith(root):
|
||||
if match is None or len(root) > len(match):
|
||||
match = root
|
||||
return match
|
||||
|
||||
|
||||
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
|
||||
model_dir = os.path.dirname(dest_path)
|
||||
model_root = _find_matching_root(model_dir)
|
||||
if not model_root:
|
||||
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
|
||||
scanner_getter_name = _infer_model_type(model_root)[1]
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
if scanner_getter is None:
|
||||
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
|
||||
scanner = await scanner_getter()
|
||||
if scanner is None:
|
||||
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
|
||||
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
|
||||
|
||||
|
||||
class HfHandler:
|
||||
"""Handle Hugging Face model browsing and download."""
|
||||
|
||||
async def set_hf_url(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload: dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
|
||||
|
||||
file_path = (payload.get("file_path") or "").strip()
|
||||
hf_url = (payload.get("hf_url") or "").strip()
|
||||
|
||||
if not file_path or not hf_url:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
|
||||
if not m:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"File not found: {file_path}"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
model_root = _find_matching_root(os.path.dirname(file_path))
|
||||
if not model_root:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
existing = await MetadataManager.load_metadata_payload(file_path)
|
||||
if existing.get("hf_url") == hf_url:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": "hf_url already set",
|
||||
"hf_url": hf_url,
|
||||
})
|
||||
|
||||
existing["hf_url"] = hf_url
|
||||
existing["from_civitai"] = False
|
||||
await MetadataManager.save_metadata(file_path, existing)
|
||||
|
||||
await _add_to_scanner_cache(file_path, existing)
|
||||
|
||||
logger.info("Set hf_url=%s for %s", hf_url, file_path)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"hf_url set to {hf_url}",
|
||||
"hf_url": hf_url,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
|
||||
"""List model-weight files from a HF repo with real file sizes.
|
||||
|
||||
@@ -252,8 +363,8 @@ class HfHandler:
|
||||
if ".." in (author, repo_name) or "." in (author, repo_name):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
|
||||
# Validate filename — must not contain path separators or ..
|
||||
if "/" in filename or "\\" in filename or ".." in filename:
|
||||
# Validate filename — must not contain path traversal
|
||||
if ".." in filename:
|
||||
return web.json_response({"error": "Invalid filename"}, status=400)
|
||||
|
||||
# Validate relative_path — must not be absolute or escape base directory
|
||||
@@ -263,54 +374,31 @@ class HfHandler:
|
||||
if ".." in relative_path.split("/") or "\\" in relative_path:
|
||||
return web.json_response({"error": "Invalid relative_path"}, status=400)
|
||||
|
||||
# Validate model_root — must not contain path traversal
|
||||
if not os.path.isabs(model_root):
|
||||
# For relative model_root, check it doesn't escape
|
||||
resolved_model_root = os.path.realpath(
|
||||
os.path.join(os.getcwd(), "models", model_root)
|
||||
)
|
||||
# Use model_root directly as the base directory — same approach as
|
||||
# CivitAI's download path (download_manager.py). No realpath, no
|
||||
# allowed-roots validation, no path-traversal check; those are
|
||||
# unnecessary when the frontend sends the path from its own dropdown
|
||||
# (populated from scanner roots). Using the "business path" directly
|
||||
# keeps dest_path consistent with scanner roots so that later folder
|
||||
# derivation (in _save_hf_metadata) works correctly.
|
||||
if os.path.isabs(model_root):
|
||||
base_dir = os.path.normpath(model_root)
|
||||
else:
|
||||
resolved_model_root = os.path.realpath(model_root)
|
||||
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
|
||||
|
||||
# Verify model_root is within a configured scanner root
|
||||
allowed_roots = set()
|
||||
for root_list in (
|
||||
config.loras_roots or [],
|
||||
config.extra_loras_roots or [],
|
||||
config.checkpoints_roots or [],
|
||||
config.extra_checkpoints_roots or [],
|
||||
config.unet_roots or [],
|
||||
config.extra_unet_roots or [],
|
||||
config.embeddings_roots or [],
|
||||
config.extra_embeddings_roots or [],
|
||||
):
|
||||
for r in root_list:
|
||||
allowed_roots.add(os.path.realpath(r))
|
||||
|
||||
if not any(resolved_model_root == root or resolved_model_root.startswith(root + os.sep) for root in allowed_roots):
|
||||
logger.warning("Invalid model_root rejected: %s", model_root)
|
||||
return web.json_response({"error": f"Invalid model_root: {model_root}"}, status=400)
|
||||
|
||||
base_dir = resolved_model_root
|
||||
folder: str = ""
|
||||
if use_default_paths:
|
||||
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
|
||||
folder = f"huggingface/{author}/{repo_name}"
|
||||
elif relative_path:
|
||||
target_dir = os.path.join(base_dir, relative_path)
|
||||
folder = relative_path
|
||||
else:
|
||||
target_dir = base_dir
|
||||
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
dest_path = os.path.join(target_dir, filename)
|
||||
# Strip HF repo subdirectory — "diffusion_models/xxx.safetensors"
|
||||
# is an HF repo convention, not meaningful for local storage.
|
||||
file_base = os.path.basename(filename)
|
||||
|
||||
# Resolve symlinks and check for path traversal escape
|
||||
real_dest = os.path.realpath(dest_path)
|
||||
real_base = os.path.realpath(target_dir)
|
||||
if not real_dest.startswith(real_base + os.sep):
|
||||
logger.warning("Path traversal blocked: %s -> %s", dest_path, real_dest)
|
||||
return web.json_response({"error": "Path traversal detected"}, status=400)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
dest_path = os.path.join(target_dir, file_base)
|
||||
|
||||
# Check if already exists (simple skip)
|
||||
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||
@@ -374,7 +462,7 @@ class HfHandler:
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if hf_success:
|
||||
await _save_hf_metadata(dest_path, repo, model_root, folder=folder)
|
||||
await _save_hf_metadata(dest_path, repo, model_root)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {dest_path}",
|
||||
@@ -402,7 +490,7 @@ class HfHandler:
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if success:
|
||||
await _save_hf_metadata(dest_path, repo, model_root, folder=folder)
|
||||
await _save_hf_metadata(dest_path, repo, model_root)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {result}",
|
||||
|
||||
@@ -573,12 +573,18 @@ class NodeRegistry:
|
||||
tab_nodes[nd["unique_id"]] = nd
|
||||
|
||||
async with self._lock:
|
||||
prev_count = len(self._tab_nodes.get(sid, {}))
|
||||
self._tab_nodes[sid] = tab_nodes
|
||||
self._waiting_clients.discard(sid)
|
||||
if not self._waiting_clients:
|
||||
self._ready.set()
|
||||
total_tabs = len(self._tab_nodes)
|
||||
|
||||
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
|
||||
if len(nodes) != prev_count or len(nodes) > 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] stored %s nodes (was %s) for client %s (total tabs: %s)",
|
||||
len(nodes), prev_count, sid, total_tabs,
|
||||
)
|
||||
|
||||
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
||||
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||
@@ -601,10 +607,17 @@ class NodeRegistry:
|
||||
longer connected."""
|
||||
async with self._lock:
|
||||
# Garbage-collect stale entries (disconnected tabs)
|
||||
stale_sids = []
|
||||
if active_sids is not None:
|
||||
for sid in list(self._tab_nodes):
|
||||
if sid not in active_sids:
|
||||
stale_sids.append(sid)
|
||||
del self._tab_nodes[sid]
|
||||
if stale_sids:
|
||||
logger.debug(
|
||||
"[LM:Registry] GC pruned %s disconnected tabs: %s",
|
||||
len(stale_sids), stale_sids,
|
||||
)
|
||||
|
||||
merged: dict[str, dict] = {}
|
||||
tab_info: dict[str, dict] = {}
|
||||
@@ -3116,6 +3129,8 @@ class NodeRegistryHandler:
|
||||
self._node_registry = node_registry
|
||||
self._prompt_server = prompt_server
|
||||
self._standalone_mode = standalone_mode
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._last_slow_path_ts: float = 0.0
|
||||
|
||||
async def register_nodes(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
@@ -3162,7 +3177,12 @@ class NodeRegistryHandler:
|
||||
)
|
||||
graph_name = node.get("graph_name")
|
||||
try:
|
||||
node["node_id"] = int(node_id)
|
||||
# Handle compound node IDs from expanded group subgraphs,
|
||||
# e.g. "252:0" → 0 (parent scope is already in graph_id)
|
||||
if isinstance(node_id, str) and ":" in node_id:
|
||||
node["node_id"] = int(node_id.rsplit(":", 1)[-1])
|
||||
else:
|
||||
node["node_id"] = int(node_id)
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -3203,42 +3223,101 @@ class NodeRegistryHandler:
|
||||
status=503,
|
||||
)
|
||||
|
||||
# Snapshot of currently-connected ComfyUI tabs
|
||||
active_sids = list(self._prompt_server.instance.sockets.keys())
|
||||
self._node_registry.prepare_for_refresh(active_sids)
|
||||
|
||||
try:
|
||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||
logger.debug(
|
||||
"Sent registry refresh request (expecting %s clients)", len(active_sids)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send registry refresh message: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Communication Error",
|
||||
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
if not await self._node_registry.wait_for_all(timeout=2.0):
|
||||
logger.warning(
|
||||
"Registry refresh timeout after 2s (%s/%s clients responded)",
|
||||
len(active_sids) - self._node_registry.pending_client_count,
|
||||
len(active_sids),
|
||||
)
|
||||
|
||||
# Re-read current sockets after the wait: a tab may have connected
|
||||
# while we were waiting, and we don't want to garbage-collect it.
|
||||
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||
|
||||
# Fast path: if the frontend has already pushed node data (via
|
||||
# afterConfigureGraph / graphChanged hooks), return it immediately
|
||||
# without triggering a WebSocket round-trip.
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
if registry_info["tab_count"] > 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] fast path: %s nodes across %s tabs %s",
|
||||
registry_info["node_count"],
|
||||
registry_info["tab_count"],
|
||||
dict(registry_info.get("tabs", {})),
|
||||
)
|
||||
return web.json_response({"success": True, "data": registry_info})
|
||||
|
||||
# Slow path: registry is empty — trigger refresh via WebSocket.
|
||||
# Serialize with an async lock so concurrent callers don't all
|
||||
# trigger separate WS refresh cycles. The second caller will
|
||||
# re-check the fast path and (usually) find populated data.
|
||||
async with self._refresh_lock:
|
||||
# Re-check after acquiring the lock — another concurrent call
|
||||
# may have populated the cache while we were waiting.
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
if registry_info["tab_count"] > 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] fast path after lock wait: %s nodes across %s tabs",
|
||||
registry_info["node_count"],
|
||||
registry_info["tab_count"],
|
||||
)
|
||||
return web.json_response({"success": True, "data": registry_info})
|
||||
|
||||
# Cooldown: if the slow path ran recently (< 2 s) and
|
||||
# returned empty, skip another WS round-trip.
|
||||
elapsed = time.monotonic() - self._last_slow_path_ts
|
||||
if elapsed < 2.0:
|
||||
logger.debug(
|
||||
"[LM:Registry] slow path cooldown (%.1fs since last refresh), returning empty",
|
||||
elapsed,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Empty Registry",
|
||||
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||
},
|
||||
status=408,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[LM:Registry] slow path: cache empty, triggering WS refresh (%s connected tabs: %s)",
|
||||
len(current_sids), list(current_sids)[:5],
|
||||
)
|
||||
active_sids = list(current_sids)
|
||||
self._node_registry.prepare_for_refresh(active_sids)
|
||||
|
||||
try:
|
||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||
logger.debug(
|
||||
"Sent registry refresh request (expecting %s clients)", len(active_sids)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send registry refresh message: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Communication Error",
|
||||
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
if not await self._node_registry.wait_for_all(timeout=0.5):
|
||||
logger.warning(
|
||||
"Registry refresh timeout after 0.5s (%s/%s clients responded)",
|
||||
len(active_sids) - self._node_registry.pending_client_count,
|
||||
len(active_sids),
|
||||
)
|
||||
|
||||
# Re-read current sockets after the wait: a tab may have connected
|
||||
# while we were waiting, and we don't want to garbage-collect it.
|
||||
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
self._last_slow_path_ts = time.monotonic()
|
||||
|
||||
if registry_info["node_count"] == 0:
|
||||
logger.warning("No nodes registered after refresh")
|
||||
logger.debug(
|
||||
"[LM:Registry] refresh OK — %s connected tab(s) but 0 compatible nodes found",
|
||||
registry_info["tab_count"],
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
@@ -3448,6 +3527,7 @@ class MiscHandlerSet:
|
||||
# Hugging Face handlers
|
||||
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
|
||||
"download_hf_model": self.hf_handler.download_hf_model,
|
||||
"set_hf_url": self.hf_handler.set_hf_url,
|
||||
# Agent skill handlers
|
||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||
|
||||
@@ -1313,9 +1313,20 @@ class ModelQueryHandler:
|
||||
}
|
||||
if include_license_flags:
|
||||
model_data = await self._service.get_model_info_by_name(model_name)
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Only return license_flags when real CivitAI model license
|
||||
# data exists. This mirrors ModelModal's guard
|
||||
# (modelData?.civitai?.model) so the preview tooltip never
|
||||
# shows misleading license icons for HF or other models
|
||||
# without actual license metadata.
|
||||
civitai_data = (model_data or {}).get("civitai") or {}
|
||||
has_license_data = (
|
||||
isinstance(civitai_data, dict)
|
||||
and isinstance(civitai_data.get("model"), dict)
|
||||
)
|
||||
if has_license_data:
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Include the user's license icon style preference so the
|
||||
# ComfyUI tooltip can pick the right set without a separate
|
||||
# API call.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import urllib.parse
|
||||
@@ -53,6 +54,7 @@ class PreviewHandler:
|
||||
|
||||
if not resolved.is_file():
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
|
||||
raise web.HTTPNotFound(text="Preview file not found")
|
||||
|
||||
# aiohttp's FileResponse handles range requests, content headers, and
|
||||
@@ -69,6 +71,35 @@ class PreviewHandler:
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return resp
|
||||
|
||||
async def _cleanup_stale_preview_url(self, normalized_preview_path: str) -> None:
|
||||
"""Fire-and-forget: clear stale preview_url from all model caches.
|
||||
|
||||
When a preview file is no longer on disk, remove its reference from
|
||||
every cached entry so subsequent list API responses return an empty
|
||||
``preview_url``, letting the frontend show the no-preview placeholder.
|
||||
"""
|
||||
try:
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
|
||||
for service_name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||
scanner = ServiceRegistry.get_service_sync(service_name)
|
||||
if scanner is None or not hasattr(scanner, "_cache"):
|
||||
continue
|
||||
cache = getattr(scanner, "_cache", None)
|
||||
if cache is None or not hasattr(cache, "clear_preview_by_path"):
|
||||
continue
|
||||
cleared = await cache.clear_preview_by_path(normalized_preview_path)
|
||||
if cleared and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
logger.info(
|
||||
"Cleared stale preview_url for %d %s entries (%s)",
|
||||
cleared,
|
||||
service_name,
|
||||
normalized_preview_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to clean up stale preview_url: %s", exc)
|
||||
|
||||
async def _stream_file(
|
||||
self, request: web.Request, path: Path
|
||||
) -> web.StreamResponse:
|
||||
|
||||
@@ -103,6 +103,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/set-hf-url", "set_hf_url"
|
||||
),
|
||||
# Agent skill endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/agent/skills", "get_agent_skills"
|
||||
|
||||
@@ -304,6 +304,20 @@ class CivArchiveClient:
|
||||
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||
if model_id is None or version_id is None:
|
||||
continue
|
||||
# CivitAI / CivArchive model IDs are small integers (typically ≤ 7
|
||||
# digits). Reject suspiciously large values that indicate the API
|
||||
# returned a malformed payload (e.g. a hash reinterpreted as an ID)
|
||||
# to avoid pointless HTTP 500 errors from CivArchive.
|
||||
_MAX_VALID_CIVITAI_ID = 100_000_000
|
||||
try:
|
||||
if int(model_id) >= _MAX_VALID_CIVITAI_ID or int(version_id) >= _MAX_VALID_CIVITAI_ID:
|
||||
logger.debug(
|
||||
"Skipping implausible CivArchive model_id=%s / version_id=%s",
|
||||
model_id, version_id,
|
||||
)
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
resolved = await self.get_model_version(model_id, version_id)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
@@ -230,6 +230,12 @@ class DownloadManager:
|
||||
Returns:
|
||||
Dict with download result
|
||||
"""
|
||||
logger.debug(
|
||||
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
||||
"source=%s, file_params=%s",
|
||||
model_id, model_version_id, source, file_params,
|
||||
)
|
||||
|
||||
# Validate that at least one identifier is provided
|
||||
if not model_id and not model_version_id:
|
||||
return {
|
||||
@@ -250,6 +256,7 @@ class DownloadManager:
|
||||
"source": source,
|
||||
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
||||
"progress": 0,
|
||||
|
||||
"status": "queued",
|
||||
"transfer_backend": self._get_model_download_backend(),
|
||||
"bytes_downloaded": 0,
|
||||
@@ -289,8 +296,8 @@ class DownloadManager:
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Download was cancelled",
|
||||
"success": True,
|
||||
"cancelled": True,
|
||||
"download_id": task_id,
|
||||
}
|
||||
finally:
|
||||
@@ -1421,14 +1428,35 @@ class DownloadManager:
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_file_id = file_params.get("id")
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format", "SafeTensor")
|
||||
target_size = file_params.get("size", "full")
|
||||
target_format = file_params.get("format")
|
||||
target_size = file_params.get("size")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
if is_primary:
|
||||
# Find primary file
|
||||
logger.debug(
|
||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
|
||||
model_version_id, len(files),
|
||||
)
|
||||
|
||||
if target_file_id:
|
||||
target_id_str = str(target_file_id)
|
||||
for f in files:
|
||||
f_id = f.get("id")
|
||||
if str(f_id) == target_id_str:
|
||||
file_info = f
|
||||
logger.debug(
|
||||
"[download] MATCH by ID: id=%s name='%s'",
|
||||
f_id, f.get("name"),
|
||||
)
|
||||
break
|
||||
if not file_info:
|
||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||
|
||||
elif is_primary:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
@@ -1439,28 +1467,41 @@ class DownloadManager:
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Match by metadata
|
||||
# Lenient metadata match: only compare fields present on both sides
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
f_meta = f.get("metadata", {})
|
||||
|
||||
# Check type match
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
# Check metadata match
|
||||
if f_meta.get("format") != target_format:
|
||||
f_meta = f.get("metadata", {})
|
||||
f_format = f_meta.get("format") or f.get("format")
|
||||
f_size = f_meta.get("size") or f.get("size")
|
||||
f_fp = f_meta.get("fp") or f.get("fp")
|
||||
|
||||
if target_format and f_format != target_format:
|
||||
continue
|
||||
if f_meta.get("size") != target_size:
|
||||
if target_size and f_size and f_size != target_size:
|
||||
continue
|
||||
if target_fp and f_meta.get("fp") != target_fp:
|
||||
if target_fp and f_fp and f_fp != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
if not file_info:
|
||||
logger.debug(
|
||||
"[download] No match found via file_params — falling back to primary file lookup",
|
||||
)
|
||||
elif not file_params:
|
||||
logger.debug(
|
||||
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
model_version_id, len(files),
|
||||
)
|
||||
|
||||
# Fallback to primary file if no match found
|
||||
if not file_info:
|
||||
logger.debug("[download] Looking for primary file as fallback")
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
@@ -1469,6 +1510,13 @@ class DownloadManager:
|
||||
),
|
||||
None,
|
||||
)
|
||||
if file_info:
|
||||
logger.debug(
|
||||
"[download] Fallback primary file selected: id=%s, name=%s",
|
||||
file_info.get("id"), file_info.get("name"),
|
||||
)
|
||||
else:
|
||||
logger.debug("[download] No primary file found in fallback lookup")
|
||||
|
||||
if not file_info:
|
||||
return {"success": False, "error": "No suitable file found in metadata"}
|
||||
|
||||
@@ -209,7 +209,21 @@ class MetadataSyncService:
|
||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||
return False, error_msg
|
||||
else:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
is_hf_source = bool(model_data.get("hf_url"))
|
||||
if is_hf_source:
|
||||
# HF-sourced model: only check CivitAI API directly.
|
||||
# CivArchive is almost guaranteed to have no record, and
|
||||
# hitting it wastes rate-limit budget.
|
||||
# Use a distinct provider name ("civitai_api" not None) so
|
||||
# downstream code does NOT interpret a "Model not found"
|
||||
# response as civitai_api_not_found — which would mark the
|
||||
# model civitai_deleted=True when it was never on CivitAI.
|
||||
try:
|
||||
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civitai_api provider: %s", exc)
|
||||
if not provider_attempts:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
|
||||
@@ -337,4 +337,25 @@ class ModelCache:
|
||||
else:
|
||||
return False # Model not found
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
async def clear_preview_by_path(self, preview_file_path: str) -> int:
|
||||
"""Clear ``preview_url`` for every cached entry referencing a file path.
|
||||
|
||||
When a preview file has been deleted from disk, this removes its
|
||||
reference from all matching cache entries so the next list-API
|
||||
response returns an empty ``preview_url`` instead of a stale URL
|
||||
that produces 404s.
|
||||
|
||||
Returns the number of entries that were updated.
|
||||
"""
|
||||
normalized = preview_file_path.replace("\\", "/")
|
||||
cleared = 0
|
||||
async with self._lock:
|
||||
for item in self.raw_data:
|
||||
cached_url = item.get("preview_url", "")
|
||||
if cached_url.replace("\\", "/") == normalized:
|
||||
item["preview_url"] = ""
|
||||
item["preview_nsfw_level"] = 0
|
||||
cleared += 1
|
||||
return cleared
|
||||
@@ -152,6 +152,11 @@ class SettingsManager:
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1":
|
||||
if not self.settings.get("use_portable_settings"):
|
||||
self.settings["use_portable_settings"] = True
|
||||
self._save_settings()
|
||||
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
self._needs_initial_save = False
|
||||
@@ -625,12 +630,37 @@ class SettingsManager:
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_set(paths: Iterable[str]) -> set[str]:
|
||||
"""Normalize an iterable of paths for set-based overlap comparison.
|
||||
|
||||
Resolves symlinks via ``os.path.realpath`` when the path exists on disk,
|
||||
then applies ``os.path.normcase`` + ``os.path.normpath`` for consistent
|
||||
cross-platform comparison. Non-string / empty entries are skipped.
|
||||
"""
|
||||
result: set[str] = set()
|
||||
for p in paths:
|
||||
if not isinstance(p, str):
|
||||
continue
|
||||
stripped = p.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if os.path.exists(stripped):
|
||||
stripped = os.path.normpath(os.path.realpath(stripped))
|
||||
result.add(os.path.normcase(stripped))
|
||||
return result
|
||||
|
||||
def _validate_folder_paths(
|
||||
self,
|
||||
library_name: str,
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
) -> None:
|
||||
"""Ensure folder paths do not overlap with other libraries."""
|
||||
"""Ensure folder paths do not overlap with other libraries.
|
||||
|
||||
Also detects checkpoints ↔ unet path overlap within the same library
|
||||
(including via symlink resolution), which is a configuration error since
|
||||
these model types must use separate physical folders.
|
||||
"""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
normalized_new: Dict[str, Dict[str, str]] = {}
|
||||
for key, values in folder_paths.items():
|
||||
@@ -668,6 +698,22 @@ class SettingsManager:
|
||||
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
||||
)
|
||||
|
||||
# Checkpoints ↔ unet overlap within the same library
|
||||
ckpt_paths = folder_paths.get("checkpoints", []) or []
|
||||
unet_paths = folder_paths.get("unet", []) or []
|
||||
if ckpt_paths and unet_paths:
|
||||
ckpt_real = self._normalize_path_set(ckpt_paths)
|
||||
unet_real = self._normalize_path_set(unet_paths)
|
||||
overlap = ckpt_real & unet_real
|
||||
if overlap:
|
||||
collisions = ", ".join(sorted(overlap))
|
||||
raise ValueError(
|
||||
f"Path(s) {collisions} are configured for both "
|
||||
f"'checkpoints' and 'unet' (diffusion models). "
|
||||
f"These model types must use separate physical folders. "
|
||||
f"Please remove one of the conflicting entries."
|
||||
)
|
||||
|
||||
def _update_active_library_entry(
|
||||
self,
|
||||
*,
|
||||
@@ -1542,8 +1588,12 @@ class SettingsManager:
|
||||
portable_switch_pending = True
|
||||
self._prepare_portable_switch(value)
|
||||
if key == "folder_paths" and isinstance(value, Mapping):
|
||||
active_name = self.get_active_library_name()
|
||||
self._validate_folder_paths(active_name, value)
|
||||
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == "extra_folder_paths" and isinstance(value, Mapping):
|
||||
active_name = self.get_active_library_name()
|
||||
self._validate_folder_paths(active_name, value)
|
||||
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == "default_lora_root":
|
||||
self._update_active_library_entry(default_lora_root=str(value))
|
||||
@@ -1797,6 +1847,9 @@ class SettingsManager:
|
||||
if key in self.settings:
|
||||
minimal[key] = copy.deepcopy(self.settings[key])
|
||||
|
||||
if self.settings.get("use_portable_settings"):
|
||||
minimal["use_portable_settings"] = True
|
||||
|
||||
if self._seed_template:
|
||||
for key, value in self._seed_template.items():
|
||||
minimal.setdefault(key, copy.deepcopy(value))
|
||||
|
||||
@@ -51,6 +51,10 @@ class BulkMetadataRefreshUseCase:
|
||||
if not model.get("skip_metadata_refresh", False)
|
||||
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
|
||||
and (not model.get("civitai") or not model["civitai"].get("id"))
|
||||
# Skip models downloaded from Hugging Face — they are not on
|
||||
# CivitAI / CivArchive. Users can still refresh them individually
|
||||
# via the right-click context menu.
|
||||
and not model.get("hf_url", "")
|
||||
and not (
|
||||
# Skip models confirmed not on CivitAI when no need to retry
|
||||
model.get("from_civitai") is False
|
||||
|
||||
@@ -12,6 +12,7 @@ from platformdirs import user_config_dir
|
||||
|
||||
|
||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -100,7 +101,11 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
|
||||
|
||||
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
||||
"""Return ``True`` when the repository settings file enables portable mode."""
|
||||
"""Return ``True`` when the env var forces it or the settings file enables it."""
|
||||
|
||||
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1":
|
||||
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
|
||||
return True
|
||||
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.6"
|
||||
version = "1.1.7"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -21,18 +21,22 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
#relinkCivitaiModal .input-group,
|
||||
#linkHfModal .input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
#relinkCivitaiModal .input-group label,
|
||||
#linkHfModal .input-group label {
|
||||
margin-bottom: var(--space-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
#relinkCivitaiModal .input-group input,
|
||||
#linkHfModal .input-group input {
|
||||
width: auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -1562,6 +1562,29 @@ input:checked + .toggle-slider:before {
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error {
|
||||
border-color: var(--lora-error);
|
||||
background-color: rgba(220, 53, 69, 0.08);
|
||||
background-color: rgba(from var(--lora-error) r g b / 0.08);
|
||||
}
|
||||
|
||||
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error:focus {
|
||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
|
||||
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.15);
|
||||
}
|
||||
|
||||
.extra-folder-path-error {
|
||||
color: var(--lora-error);
|
||||
font-size: 0.8em;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.extra-folder-path-error.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.extra-folder-path-row .path-controls .remove-path-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -112,6 +112,18 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelDownload(downloadId) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error cancelling download:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||
const pageState = this.getPageState();
|
||||
|
||||
|
||||
@@ -416,6 +416,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
cleanupCallbacks();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||
progressUI.complete(data.summary || 'Enrich complete');
|
||||
showToast(
|
||||
'toast.agent.enrichComplete',
|
||||
@@ -428,6 +429,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
|
||||
const onError = (data) => {
|
||||
cleanupCallbacks();
|
||||
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||
state.loadingManager.hide();
|
||||
showToast(
|
||||
'toast.agent.enrichFailed',
|
||||
@@ -441,6 +443,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
await agentManager.executeSkill('enrich_hf_metadata', modelPaths);
|
||||
} catch (error) {
|
||||
cleanupCallbacks();
|
||||
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||
state.loadingManager.hide();
|
||||
showToast(
|
||||
'toast.agent.enrichFailed',
|
||||
|
||||
@@ -32,6 +32,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
if (!enrichItem) return;
|
||||
const hasHfUrl = !!card.dataset.hf_url;
|
||||
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||
enrichItem.title = hasHfUrl
|
||||
? ''
|
||||
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
|
||||
@@ -187,6 +187,74 @@ export const ModelContextMenuMixin = {
|
||||
setTimeout(() => urlInput.focus(), 50);
|
||||
},
|
||||
|
||||
// HuggingFace linking methods
|
||||
showLinkHfModal() {
|
||||
const filePath = this.currentCard.dataset.filepath;
|
||||
if (!filePath) return;
|
||||
|
||||
const confirmBtn = document.getElementById('confirmLinkHfBtn');
|
||||
const urlInput = document.getElementById('hfModelUrl');
|
||||
const errorDiv = document.getElementById('hfModelUrlError');
|
||||
|
||||
if (this._boundLinkHfHandler) {
|
||||
confirmBtn.removeEventListener('click', this._boundLinkHfHandler);
|
||||
}
|
||||
|
||||
this._boundLinkHfHandler = async () => {
|
||||
const hfUrl = urlInput.value.trim();
|
||||
if (!hfUrl) {
|
||||
errorDiv.textContent = 'Please enter a HuggingFace repository URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
const hfPattern = /^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/?$/;
|
||||
if (!hfPattern.test(hfUrl)) {
|
||||
errorDiv.textContent = 'Invalid URL format. Expected: https://huggingface.co/user/repo';
|
||||
return;
|
||||
}
|
||||
|
||||
errorDiv.textContent = '';
|
||||
modalManager.closeModal('linkHfModal');
|
||||
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Linking to HuggingFace...');
|
||||
|
||||
const response = await fetch('/api/lm/set-hf-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath, hf_url: hfUrl }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `Request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('toast.contextMenu.linkHfSuccess', {}, 'success');
|
||||
await this.resetAndReload();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to link model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error linking model to HuggingFace:', error);
|
||||
showToast('toast.contextMenu.linkHfFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', this._boundLinkHfHandler);
|
||||
|
||||
urlInput.value = '';
|
||||
errorDiv.textContent = '';
|
||||
|
||||
modalManager.showModal('linkHfModal');
|
||||
|
||||
setTimeout(() => urlInput.focus(), 50);
|
||||
},
|
||||
|
||||
extractModelVersionId(url) {
|
||||
return extractCivitaiModelUrlParts(url);
|
||||
},
|
||||
@@ -295,6 +363,9 @@ export const ModelContextMenuMixin = {
|
||||
case 'relink-civitai':
|
||||
this.showRelinkCivitaiModal();
|
||||
return true;
|
||||
case 'link-hf':
|
||||
this.showLinkHfModal();
|
||||
return true;
|
||||
case 'set-nsfw':
|
||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||
return true;
|
||||
|
||||
@@ -358,7 +358,7 @@ class RecipeCard {
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${this.recipe.title}">`
|
||||
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
|
||||
@@ -757,7 +757,7 @@ class RecipeModal {
|
||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||
<source src="${lora.preview_url}" type="video/mp4">
|
||||
</video>` :
|
||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||
|
||||
let loraItemClass = 'recipe-lora-item';
|
||||
if (existsLocally) {
|
||||
@@ -1606,7 +1606,7 @@ class RecipeModal {
|
||||
<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||
<source src="${previewUrl}" type="video/mp4">
|
||||
</video>
|
||||
` : `<img src="${previewUrl}" alt="Checkpoint preview">`;
|
||||
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||
|
||||
const badge = existsLocally ? `
|
||||
<div class="local-badge">
|
||||
|
||||
@@ -643,7 +643,7 @@ export function createModelCard(model, modelType) {
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${isVideo ?
|
||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
<div class="card-header">
|
||||
${shouldBlur ?
|
||||
|
||||
@@ -432,7 +432,7 @@ function renderMediaMarkup(version) {
|
||||
|
||||
return `
|
||||
<div class="version-media">
|
||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
|
||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,17 @@ export class BulkMissingLoraDownloadManager {
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let currentLoraProgress = 0;
|
||||
let cancelled = false;
|
||||
|
||||
loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
try {
|
||||
await this.loraApiClient.cancelDownload(batchDownloadId);
|
||||
} catch (e) {
|
||||
console.error('Cancel request failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up WebSocket message handler
|
||||
ws.onmessage = (event) => {
|
||||
@@ -207,6 +218,11 @@ export class BulkMissingLoraDownloadManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'cancelled') {
|
||||
cancelled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Process progress updates
|
||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||
currentLoraProgress = data.progress;
|
||||
@@ -249,6 +265,8 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Download each LoRA sequentially
|
||||
for (let i = 0; i < lorasToDownload.length; i++) {
|
||||
if (cancelled) break;
|
||||
|
||||
const lora = lorasToDownload[i];
|
||||
|
||||
currentLoraProgress = 0;
|
||||
@@ -275,11 +293,13 @@ export class BulkMissingLoraDownloadManager {
|
||||
modelId,
|
||||
versionId,
|
||||
loraRoot,
|
||||
'', // Empty relative path, use default paths
|
||||
'',
|
||||
useDefaultPaths,
|
||||
batchDownloadId
|
||||
);
|
||||
|
||||
if (cancelled) break;
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
failedDownloads++;
|
||||
@@ -288,8 +308,10 @@ export class BulkMissingLoraDownloadManager {
|
||||
updateProgress(100, completedDownloads, '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
if (!cancelled) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +322,10 @@ export class BulkMissingLoraDownloadManager {
|
||||
loadingManager.hide();
|
||||
|
||||
// Show completion message
|
||||
if (failedDownloads === 0) {
|
||||
if (cancelled) {
|
||||
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||
} else if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
|
||||
@@ -728,14 +728,23 @@ export class DownloadManager {
|
||||
|
||||
confirmFileSelection() {
|
||||
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||
if (!selectedRadio) return;
|
||||
if (!selectedRadio) {
|
||||
console.warn('[download] confirmFileSelection: no radio button checked');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = this.currentVersion;
|
||||
if (!version) return;
|
||||
if (!version) {
|
||||
console.warn('[download] confirmFileSelection: no currentVersion set');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||
|
||||
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
||||
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
|
||||
|
||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||
document.getElementById('locationStep').style.display = 'block';
|
||||
this.proceedToLocationContent();
|
||||
@@ -872,16 +881,26 @@ export class DownloadManager {
|
||||
const displayName = versionName || `#${versionId}`;
|
||||
let ws = null;
|
||||
let updateProgress = () => { };
|
||||
let cancelled = false;
|
||||
const downloadId = Date.now().toString();
|
||||
|
||||
try {
|
||||
this.loadingManager.restoreProgressBar();
|
||||
updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||
updateProgress(0, 0, displayName);
|
||||
|
||||
const downloadId = Date.now().toString();
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
|
||||
this.loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
try {
|
||||
await this.apiClient.cancelDownload(downloadId);
|
||||
} catch (e) {
|
||||
console.error('Cancel request failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
ws.onmessage = event => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
@@ -890,6 +909,12 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'cancelled') {
|
||||
cancelled = true;
|
||||
this.loadingManager.setStatus(translate('modals.download.status.cancelled', {}, 'Download cancelled'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
@@ -928,6 +953,10 @@ export class DownloadManager {
|
||||
fileParams
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response?.skipped) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
updateProgress(100, 0, displayName);
|
||||
@@ -968,8 +997,12 @@ export class DownloadManager {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to download model version:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
if (cancelled) {
|
||||
console.log('Download cancelled by user:', downloadId);
|
||||
} else {
|
||||
console.error('Failed to download model version:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
@@ -989,16 +1022,33 @@ export class DownloadManager {
|
||||
const totalFiles = this.hfSelectedFiles.length;
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||
|
||||
let cancelled = false;
|
||||
let currentDownloadId = null;
|
||||
|
||||
this.loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
if (currentDownloadId) {
|
||||
try {
|
||||
await this.apiClient.cancelDownload(currentDownloadId);
|
||||
} catch (e) {
|
||||
console.error('Cancel request failed:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
let completedDownloads = 0;
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
if (cancelled) break;
|
||||
|
||||
const filename = this.hfSelectedFiles[i];
|
||||
updateProgress(0, completedDownloads, filename);
|
||||
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||
|
||||
const downloadId = Date.now().toString() + '_' + i;
|
||||
currentDownloadId = Date.now().toString() + '_' + i;
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${currentDownloadId}`);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -1006,12 +1056,13 @@ export class DownloadManager {
|
||||
ws.onerror = reject;
|
||||
});
|
||||
|
||||
// Capture completed count at WS creation time so progress
|
||||
// updates arriving after completedDownloads increments still
|
||||
// show the correct "N / total" position.
|
||||
const snapshotCompleted = completedDownloads;
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'cancelled') {
|
||||
cancelled = true;
|
||||
return;
|
||||
}
|
||||
if (data.status === 'progress') {
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
@@ -1029,9 +1080,11 @@ export class DownloadManager {
|
||||
modelRoot,
|
||||
relativePath: targetFolder,
|
||||
useDefaultPaths,
|
||||
download_id: downloadId,
|
||||
download_id: currentDownloadId,
|
||||
});
|
||||
|
||||
if (cancelled) break;
|
||||
|
||||
if (response?.success) {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, filename);
|
||||
@@ -1041,13 +1094,19 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
// Reload page data — model is already in scanner cache via backend
|
||||
if (cancelled) {
|
||||
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||
} else {
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
}
|
||||
await resetAndReload(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to download HF model:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
if (!cancelled) {
|
||||
console.error('Failed to download HF model:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
@@ -1426,12 +1485,23 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const fileParams = this.selectedFile ? {
|
||||
id: this.selectedFile.id,
|
||||
type: this.selectedFile.type || 'Model',
|
||||
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
||||
size: this.selectedFile.metadata?.size || 'full',
|
||||
fp: this.selectedFile.metadata?.fp,
|
||||
format: this.selectedFile.metadata?.format || null,
|
||||
size: this.selectedFile.metadata?.size || null,
|
||||
fp: this.selectedFile.metadata?.fp || null,
|
||||
} : null;
|
||||
|
||||
if (fileParams) {
|
||||
console.log('[download] startDownload (single): fileParams built from selectedFile — id=%s, type=%s, format=%s, size=%s, fp=%s',
|
||||
fileParams.id, fileParams.type, fileParams.format, fileParams.size, fileParams.fp);
|
||||
} else {
|
||||
console.log('[download] startDownload (single): this.selectedFile is null — no file selection, will download primary/default file. version=%s has %d files',
|
||||
this.currentVersion?.id, (this.currentVersion?.files || []).length);
|
||||
}
|
||||
|
||||
modalManager.closeModal('downloadModal');
|
||||
|
||||
return this.executeDownloadWithProgress({
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
@@ -1470,11 +1540,27 @@ export class DownloadManager {
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let cancelled = false;
|
||||
|
||||
loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
try {
|
||||
await this.apiClient.cancelDownload(batchDownloadId);
|
||||
} catch (e) {
|
||||
console.error('Cancel request failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'download_id') return;
|
||||
|
||||
if (data.status === 'cancelled') {
|
||||
cancelled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
||||
const current = downloadItems[completedDownloads + failedDownloads];
|
||||
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
|
||||
@@ -1493,6 +1579,8 @@ export class DownloadManager {
|
||||
});
|
||||
|
||||
for (let i = 0; i < downloadItems.length; i++) {
|
||||
if (cancelled) break;
|
||||
|
||||
const item = downloadItems[i];
|
||||
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
|
||||
const isHf = item.source === 'huggingface';
|
||||
@@ -1503,7 +1591,6 @@ export class DownloadManager {
|
||||
try {
|
||||
let response;
|
||||
if (isHf) {
|
||||
// Per-file WebSocket for real-time progress
|
||||
const downloadId = Date.now().toString() + '_hf_' + i;
|
||||
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
try {
|
||||
@@ -1537,6 +1624,8 @@ export class DownloadManager {
|
||||
wsHf.close();
|
||||
}
|
||||
} else {
|
||||
console.log('[download] batch download: fileParams NOT passed for modelId=%s, versionId=%s — backend will use primary file',
|
||||
item.modelId, item.selectedVersion?.id);
|
||||
response = await this.apiClient.downloadModel(
|
||||
item.modelId,
|
||||
item.selectedVersion.id,
|
||||
@@ -1548,6 +1637,8 @@ export class DownloadManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (cancelled) break;
|
||||
|
||||
if (!response.success) {
|
||||
failedDownloads++;
|
||||
} else {
|
||||
@@ -1555,15 +1646,20 @@ export class DownloadManager {
|
||||
updateProgress(100, completedDownloads, '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to download ${name}:`, err);
|
||||
failedDownloads++;
|
||||
if (!cancelled) {
|
||||
console.error(`Failed to download ${name}:`, err);
|
||||
failedDownloads++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
loadingManager.hide();
|
||||
|
||||
if (failedDownloads === 0) {
|
||||
if (cancelled) {
|
||||
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||
} else if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
@@ -1581,6 +1677,10 @@ export class DownloadManager {
|
||||
modelRoot = '',
|
||||
targetFolder = ''
|
||||
} = {}) {
|
||||
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
|
||||
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
|
||||
modelType, modelId, versionId, versionName);
|
||||
|
||||
try {
|
||||
this.apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
|
||||
@@ -281,6 +281,10 @@ export class LoadingManager {
|
||||
// Initialize transfer stats with empty data
|
||||
updateTransferStats();
|
||||
|
||||
if (this.cancelButton) {
|
||||
this.loadingContent.appendChild(this.cancelButton);
|
||||
}
|
||||
|
||||
// Return update function
|
||||
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
|
||||
// Update current item progress
|
||||
|
||||
@@ -264,6 +264,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add linkHfModal registration
|
||||
const linkHfModal = document.getElementById('linkHfModal');
|
||||
if (linkHfModal) {
|
||||
this.registerModal('linkHfModal', {
|
||||
element: linkHfModal,
|
||||
onClose: () => {
|
||||
this.getModal('linkHfModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Add exampleAccessModal registration
|
||||
const exampleAccessModal = document.getElementById('exampleAccessModal');
|
||||
if (exampleAccessModal) {
|
||||
|
||||
@@ -1693,13 +1693,15 @@ export class SettingsManager {
|
||||
<input type="text" class="extra-folder-path-input"
|
||||
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
|
||||
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button type="button" class="remove-path-btn"
|
||||
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="extra-folder-path-error"></div>
|
||||
`;
|
||||
|
||||
container.appendChild(row);
|
||||
@@ -1713,7 +1715,63 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
clearExtraFolderPathError(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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_clearAllExtraFolderPathErrors() {
|
||||
document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
|
||||
input.classList.remove('has-error');
|
||||
});
|
||||
document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
|
||||
el.classList.remove('visible');
|
||||
el.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
_markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) {
|
||||
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeExtraFolderPathRow(btn, modelType) {
|
||||
const row = btn.closest('.extra-folder-path-row');
|
||||
if (row) {
|
||||
row.remove();
|
||||
this.updateExtraFolderPaths(modelType);
|
||||
}
|
||||
}
|
||||
|
||||
async updateExtraFolderPaths(changedModelType) {
|
||||
// Clear previous errors
|
||||
this._clearAllExtraFolderPathErrors();
|
||||
|
||||
const extraFolderPaths = {};
|
||||
|
||||
// Collect paths for all model types
|
||||
@@ -1734,6 +1792,32 @@ export class SettingsManager {
|
||||
extraFolderPaths[modelType] = paths;
|
||||
});
|
||||
|
||||
// Client-side pre-check: checkpoints and unet must not share the same path.
|
||||
// Normalise paths to reduce false negatives vs the backend's realpath + normcase.
|
||||
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
|
||||
const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise));
|
||||
const unetSet = new Set((extraFolderPaths.unet || []).map(normalise));
|
||||
const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
|
||||
const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
|
||||
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
|
||||
|
||||
if (hasOverlap) {
|
||||
// Error message only on the side the user just edited.
|
||||
// The other side gets red border only (passive conflict indicator).
|
||||
if (changedModelType === 'checkpoints') {
|
||||
this._markExtraFolderPathsError('checkpoints', ckptOverlap, true);
|
||||
this._markExtraFolderPathsError('unet', unetOverlap, false);
|
||||
} else if (changedModelType === 'unet') {
|
||||
this._markExtraFolderPathsError('unet', unetOverlap, true);
|
||||
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
|
||||
} else {
|
||||
// Pre-existing conflict from direct config edit — mark both without messages
|
||||
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
|
||||
this._markExtraFolderPathsError('unet', unetOverlap, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if paths have actually changed
|
||||
const currentPaths = state.global.settings.extra_folder_paths || {};
|
||||
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
|
||||
|
||||
@@ -168,6 +168,18 @@ export class DownloadManager {
|
||||
let failedDownloads = 0;
|
||||
let accessFailures = 0;
|
||||
let currentLoraProgress = 0;
|
||||
let cancelled = false;
|
||||
|
||||
this.importManager.loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
try {
|
||||
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
await loraClient.cancelDownload(batchDownloadId);
|
||||
} catch (e) {
|
||||
console.error('Cancel request failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up progress tracking for current download
|
||||
ws.onmessage = (event) => {
|
||||
@@ -179,6 +191,11 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'cancelled') {
|
||||
cancelled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Process progress updates for our current active download
|
||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||
// Update current LoRA progress
|
||||
@@ -221,6 +238,8 @@ export class DownloadManager {
|
||||
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
||||
|
||||
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
||||
if (cancelled) break;
|
||||
|
||||
const lora = this.importManager.downloadableLoRAs[i];
|
||||
|
||||
// Reset current LoRA progress for new download
|
||||
@@ -241,15 +260,13 @@ export class DownloadManager {
|
||||
batchDownloadId
|
||||
);
|
||||
|
||||
if (cancelled) break;
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
||||
|
||||
failedDownloads++;
|
||||
// Continue with next download
|
||||
} else {
|
||||
completedDownloads++;
|
||||
|
||||
// Update progress to show completion of current LoRA
|
||||
updateProgress(100, completedDownloads, '');
|
||||
|
||||
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
|
||||
@@ -259,9 +276,10 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
} catch (downloadError) {
|
||||
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
||||
failedDownloads++;
|
||||
// Continue with next download
|
||||
if (!cancelled) {
|
||||
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
||||
failedDownloads++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +287,10 @@ export class DownloadManager {
|
||||
ws.close();
|
||||
|
||||
// Show appropriate completion message based on results
|
||||
if (failedDownloads === 0) {
|
||||
if (cancelled) {
|
||||
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||
} else if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
if (accessFailures > 0) {
|
||||
|
||||
@@ -552,6 +552,8 @@ async function fetchWorkflowRegistry() {
|
||||
if (!registryData.success) {
|
||||
if (registryData.error === 'Standalone Mode Active') {
|
||||
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
|
||||
} else if (registryData.error === 'Empty Registry') {
|
||||
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
|
||||
} else {
|
||||
showToast('toast.general.failedWorkflowInfo', {}, 'error');
|
||||
}
|
||||
|
||||
@@ -12,7 +12,19 @@
|
||||
<div id="checkpointContextMenu" class="context-menu" style="display: none;">
|
||||
<!-- Metadata -->
|
||||
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
|
||||
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{{ t('loras.contextMenu.linkModel') }}</span>
|
||||
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="relink-civitai">
|
||||
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="link-hf">
|
||||
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Workflow -->
|
||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||
|
||||
@@ -12,8 +12,18 @@
|
||||
<div class="context-menu-item" data-action="check-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('loras.contextMenu.checkUpdates') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="relink-civitai">
|
||||
<i class="fas fa-link"></i> <span>{{ t('loras.contextMenu.relinkCivitai') }}</span>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{{ t('loras.contextMenu.linkModel') }}</span>
|
||||
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="relink-civitai">
|
||||
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="link-hf">
|
||||
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="enrich-hf-llm">
|
||||
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{% include 'components/modals/update_modal.html' %}
|
||||
{% include 'components/modals/help_modal.html' %}
|
||||
{% include 'components/modals/relink_civitai_modal.html' %}
|
||||
{% include 'components/modals/link_hf_modal.html' %}
|
||||
{% include 'components/modals/example_access_modal.html' %}
|
||||
{% include 'components/modals/download_modal.html' %}
|
||||
{% include 'components/modals/move_modal.html' %}
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank">
|
||||
Priority Tags Configuration Guide
|
||||
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
|
||||
<li>
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/AI-Provider-Setup" target="_blank">
|
||||
AI Provider Setup
|
||||
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!-- Link to HuggingFace Modal -->
|
||||
<div id="linkHfModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button class="close" onclick="modalManager.closeModal('linkHfModal')">×</button>
|
||||
<h2>{{ t('modals.linkHuggingFace.title') }}</h2>
|
||||
<div class="warning-box">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>{{ t('modals.linkHuggingFace.infoText') }}</p>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="hfModelUrl">{{ t('modals.linkHuggingFace.urlLabel') }}</label>
|
||||
<input type="text" id="hfModelUrl" placeholder="{{ t('modals.linkHuggingFace.urlPlaceholder') }}" />
|
||||
<div class="input-error" id="hfModelUrlError"></div>
|
||||
<div class="input-help">
|
||||
{{ t('modals.linkHuggingFace.helpText') }}<br>
|
||||
<strong>https://huggingface.co/user/repo</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="modalManager.closeModal('linkHfModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="confirm-btn" id="confirmLinkHfBtn">{{ t('modals.linkHuggingFace.confirmAction') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,7 +12,19 @@
|
||||
<div id="embeddingContextMenu" class="context-menu" style="display: none;">
|
||||
<!-- Metadata -->
|
||||
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
|
||||
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{{ t('loras.contextMenu.linkModel') }}</span>
|
||||
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="relink-civitai">
|
||||
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="link-hf">
|
||||
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Workflow -->
|
||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||
|
||||
@@ -728,6 +728,54 @@ async def test_register_nodes_includes_capabilities():
|
||||
assert stored_node["widget_names"] == ["ckpt_name"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_nodes_accepts_compound_node_ids():
|
||||
"""Subgraph nodes from expanded group nodes have compound IDs like '252:0'."""
|
||||
node_registry = NodeRegistry()
|
||||
handler = NodeRegistryHandler(
|
||||
node_registry=node_registry,
|
||||
prompt_server=FakePromptServer,
|
||||
standalone_mode=False,
|
||||
)
|
||||
|
||||
request = FakeRequest(
|
||||
json_data={
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "252:0",
|
||||
"graph_id": "252",
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"title": "Checkpoint Loader (subgraph)",
|
||||
},
|
||||
{
|
||||
"node_id": "252:1",
|
||||
"graph_id": "252",
|
||||
"type": "CLIPLoader",
|
||||
"title": "CLIP Loader (subgraph)",
|
||||
},
|
||||
],
|
||||
"client_id": "test-client-1",
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.register_nodes(request)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert "2 nodes registered" in payload["message"]
|
||||
|
||||
registry = await node_registry.get_merged_registry()
|
||||
assert registry["node_count"] == 2
|
||||
|
||||
nodes_map = registry["nodes"]
|
||||
assert "252:0" in nodes_map
|
||||
assert "252:1" in nodes_map
|
||||
assert nodes_map["252:0"]["id"] == 0
|
||||
assert nodes_map["252:0"]["graph_id"] == "252"
|
||||
assert nodes_map["252:1"]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_node_widget_sends_payload():
|
||||
send_calls: list[tuple[str, dict]] = []
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for settings path resolution."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.settings_paths import _should_use_portable_settings
|
||||
|
||||
|
||||
class TestShouldUsePortableSettings:
|
||||
"""Tests for _should_use_portable_settings()."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_value, settings_flag, expected",
|
||||
[
|
||||
("1", False, True), # env = 1 overrides settings.json false
|
||||
("1", True, True), # env = 1 matches settings.json true
|
||||
("0", False, False), # env = 0 → rely on settings.json
|
||||
("0", True, True), # env = 0 → rely on settings.json
|
||||
("", False, False), # unset → rely on settings.json
|
||||
("", True, True), # unset → rely on settings.json
|
||||
],
|
||||
)
|
||||
def test_env_var_overrides_settings(self, tmp_path, env_value, settings_flag, expected):
|
||||
"""The LORA_MANAGER_PORTABLE env var takes precedence over settings.json."""
|
||||
settings_file = tmp_path / "settings.json"
|
||||
settings_file.write_text(
|
||||
json.dumps({"use_portable_settings": settings_flag})
|
||||
)
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
if env_value:
|
||||
mp.setenv("LORA_MANAGER_PORTABLE", env_value)
|
||||
else:
|
||||
mp.delenv("LORA_MANAGER_PORTABLE", raising=False)
|
||||
|
||||
result = _should_use_portable_settings(str(settings_file), logging.getLogger())
|
||||
assert result == expected
|
||||
|
||||
def test_missing_file_without_env(self, tmp_path):
|
||||
"""Without env var, missing settings file returns False."""
|
||||
missing = tmp_path / "nonexistent.json"
|
||||
|
||||
result = _should_use_portable_settings(str(missing), logging.getLogger())
|
||||
assert result is False
|
||||
|
||||
def test_missing_file_with_env(self, tmp_path):
|
||||
"""With env var, even a missing settings file returns True."""
|
||||
missing = tmp_path / "nonexistent.json"
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setenv("LORA_MANAGER_PORTABLE", "1")
|
||||
result = _should_use_portable_settings(str(missing), logging.getLogger())
|
||||
assert result is True
|
||||
@@ -271,12 +271,14 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 2px 2px 24px 2px; /* Reserve bottom space for clear button */
|
||||
resize: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--comfy-textarea-font-size, 10px);
|
||||
font-family: monospace;
|
||||
/* resize:none set here (0,2,0). Overridden to vertical in app mode
|
||||
by the :global(.\[\&_textarea\]\:resize-y) .text-input rule below. */
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
||||
@@ -350,4 +352,19 @@ onUnmounted(() => {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Non-scoped !important override: scoped .text-input[data-v-xxx] (0,2,0)
|
||||
beats the app-mode Tailwind rule (0,1,1), so we use !important here to
|
||||
force resize:vertical only when inside the app-mode widget list.
|
||||
The data-testid attribute scoping prevents it from leaking into graph
|
||||
mode. This is the only !important in the widget stylesheets.
|
||||
-->
|
||||
<style>
|
||||
[data-testid="app-mode-widget-item"] textarea,
|
||||
[data-testid="builder-widget-item"] textarea {
|
||||
resize: vertical !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
+13
-9
@@ -553,22 +553,22 @@ function normalizeAutocompleteWidgetValues(node: any, info: any) {
|
||||
|
||||
function applyAutocompleteTextLayoutFix(
|
||||
widget: any,
|
||||
container: HTMLElement | undefined,
|
||||
_container: HTMLElement | undefined,
|
||||
isVueMode: boolean
|
||||
): void {
|
||||
// In Vue rendering mode the WidgetDOM wrapper handles sizing, so we
|
||||
// only provide a computeSize hint and leave the container unconstrained.
|
||||
// In canvas mode we clear all custom sizing so LiteGraph's default
|
||||
// widget-area layout takes over. Neither path sets a hard max-height;
|
||||
// the textarea can grow freely (e.g. in app mode where
|
||||
// [&_textarea]:resize-y applies).
|
||||
if (isVueMode) {
|
||||
;(widget as any).computeLayoutSize = undefined
|
||||
widget.computeSize = (width?: number) =>
|
||||
[width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4]
|
||||
if (container) {
|
||||
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT}px`
|
||||
}
|
||||
} else {
|
||||
delete (widget as any).computeLayoutSize
|
||||
delete (widget as any).computeSize
|
||||
if (container) {
|
||||
container.style.minHeight = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,8 +743,12 @@ function createAutocompleteTextWidgetFactory(
|
||||
vueApps.set(appKey, vueApp)
|
||||
|
||||
if (maxHeight) {
|
||||
container.style.maxHeight = `${maxHeight}px`
|
||||
container.style.minHeight = `${maxHeight}px`
|
||||
// Set only minHeight as a true minimum — remove maxHeight so the
|
||||
// textarea can grow when the user resizes it in app mode (where
|
||||
// [&_textarea]:resize-y applies). Graph mode (canvas & Vue render)
|
||||
// is unaffected because LiteGraph's layout system still governs
|
||||
// the widget area size.
|
||||
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT}px`
|
||||
}
|
||||
|
||||
if (modelType === 'loras') {
|
||||
|
||||
@@ -120,10 +120,27 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
|
||||
contain:size breaks the feedback loop — the container's intrinsic size
|
||||
is determined solely by CSS, not by how many LoRAs are inside. */
|
||||
.lm-loras-container.lm-vue-node {
|
||||
height: 100%;
|
||||
min-height: var(--comfy-widget-min-height, 200px);
|
||||
contain: layout size;
|
||||
}
|
||||
|
||||
.lm-loras-container:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
|
||||
Same technique as .lm-loras-container.lm-vue-node above. */
|
||||
.comfy-tags-container.lm-vue-node {
|
||||
height: 100%;
|
||||
min-height: var(--comfy-widget-min-height, 150px);
|
||||
contain: layout size;
|
||||
}
|
||||
|
||||
.lm-lora-empty-state {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
|
||||
@@ -2,19 +2,14 @@ import { createToggle, createArrowButton, createDragHandle, updateEntrySelection
|
||||
import {
|
||||
parseLoraValue,
|
||||
formatLoraValue,
|
||||
updateWidgetHeight,
|
||||
shouldShowClipEntry,
|
||||
syncClipStrengthIfCollapsed,
|
||||
LORA_ENTRY_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
CONTAINER_PADDING,
|
||||
EMPTY_CONTAINER_HEIGHT
|
||||
syncClipStrengthIfCollapsed
|
||||
} from "./loras_widget_utils.js";
|
||||
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
|
||||
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas, enableListWheelScroll } from "./utils.js";
|
||||
import { PreviewTooltip } from "./preview_tooltip.js";
|
||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||
import { getStrengthStepPreference, getLoraWidgetMaxVisibleLoras } from "./settings.js";
|
||||
import { getStrengthStepPreference } from "./settings.js";
|
||||
|
||||
export function addLorasWidget(node, name, opts, callback) {
|
||||
ensureLmStyles();
|
||||
@@ -29,15 +24,13 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
// Set initial height using CSS variables approach
|
||||
const defaultHeight = 200;
|
||||
|
||||
// In Vue/node-2.0 mode, cap the widget height so it shows at most N entries.
|
||||
// This prevents content from driving the node size beyond the cap.
|
||||
// canvas/legacy mode is unaffected.
|
||||
// Set a fixed minimum height so the node has a reasonable starting size.
|
||||
// Adding or removing LoRAs does NOT change the node size — the container
|
||||
// scrolls when content exceeds the allocated space.
|
||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
||||
|
||||
if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
|
||||
const maxLoras = getLoraWidgetMaxVisibleLoras();
|
||||
const gap = 5; // flex gap from .lm-loras-container CSS
|
||||
const maxH = CONTAINER_PADDING + HEADER_HEIGHT + maxLoras * LORA_ENTRY_HEIGHT + maxLoras * gap;
|
||||
container.style.maxHeight = `${maxH}px`;
|
||||
container.style.setProperty('--comfy-widget-max-height', `${maxH}px`);
|
||||
container.classList.add('lm-vue-node');
|
||||
// Window capture-phase hook: scroll the widget instead of zooming the canvas
|
||||
// when the wheel is over a scrollable loras list.
|
||||
enableListWheelScroll(container);
|
||||
@@ -210,9 +203,6 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
emptyMessage.textContent = "No LoRAs added";
|
||||
emptyMessage.className = "lm-lora-empty-state";
|
||||
container.appendChild(emptyMessage);
|
||||
|
||||
// Set fixed height for empty state
|
||||
updateWidgetHeight(container, EMPTY_CONTAINER_HEIGHT, defaultHeight, node);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -259,9 +249,6 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
// Initialize the header drag functionality
|
||||
initHeaderDrag(header, widget, renderLoras);
|
||||
|
||||
// Track the total visible entries for height calculation
|
||||
let totalVisibleEntries = lorasData.length;
|
||||
|
||||
// Render each lora entry
|
||||
lorasData.forEach((loraData) => {
|
||||
const { name, strength, clipStrength, active } = loraData;
|
||||
@@ -533,7 +520,6 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
|
||||
// If expanded, show the clip entry
|
||||
if (isExpanded) {
|
||||
totalVisibleEntries++;
|
||||
const clipEl = document.createElement("div");
|
||||
clipEl.className = "lm-lora-clip-entry";
|
||||
|
||||
@@ -657,10 +643,6 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate height based on number of loras and fixed sizes
|
||||
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT);
|
||||
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
|
||||
|
||||
// After all LoRA elements are created, apply selection state as the last step
|
||||
// This ensures the selection state is not overwritten
|
||||
container.querySelectorAll('.lm-lora-entry').forEach(entry => {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Fixed sizes for component calculations
|
||||
export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
|
||||
export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry
|
||||
export const HEADER_HEIGHT = 32; // Height of the header section
|
||||
export const CONTAINER_PADDING = 12; // Top and bottom padding
|
||||
export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
|
||||
|
||||
// Parse LoRA entries from value
|
||||
export function parseLoraValue(value) {
|
||||
if (!value) return [];
|
||||
@@ -18,23 +11,6 @@ export function formatLoraValue(loras) {
|
||||
return loras;
|
||||
}
|
||||
|
||||
// Function to update widget height consistently
|
||||
export function updateWidgetHeight(container, height, defaultHeight, node) {
|
||||
// Ensure minimum height
|
||||
const finalHeight = Math.max(defaultHeight, height);
|
||||
|
||||
// Update CSS variables
|
||||
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
|
||||
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
|
||||
|
||||
// Force node to update size after a short delay to ensure DOM is updated
|
||||
if (node) {
|
||||
setTimeout(() => {
|
||||
node.setDirtyCanvas(true, true);
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if clip entry should be shown - now based on expanded property or initial diff values
|
||||
export function shouldShowClipEntry(loraData) {
|
||||
// If expanded property exists, use that
|
||||
|
||||
@@ -39,9 +39,6 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
|
||||
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
|
||||
const STRENGTH_STEP_DEFAULT = 0.05;
|
||||
|
||||
const LORA_WIDGET_MAX_VISIBLE_SETTING_ID = "loramanager.lora_widget_max_visible_loras";
|
||||
const LORA_WIDGET_MAX_VISIBLE_DEFAULT = 12;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
@@ -363,32 +360,6 @@ const getStrengthStepPreference = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
const getLoraWidgetMaxVisibleLoras = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
return () => {
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (!settingManager || typeof settingManager.get !== "function") {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: settings API unavailable, using default max visible loras.");
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return LORA_WIDGET_MAX_VISIBLE_DEFAULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = settingManager.get(LORA_WIDGET_MAX_VISIBLE_SETTING_ID);
|
||||
return value ?? LORA_WIDGET_MAX_VISIBLE_DEFAULT;
|
||||
} catch (error) {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: unable to read max visible loras setting, using default.", error);
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return LORA_WIDGET_MAX_VISIBLE_DEFAULT;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Register Extension with All Settings
|
||||
// ============================================================================
|
||||
@@ -492,19 +463,6 @@ app.registerExtension({
|
||||
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
|
||||
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
|
||||
},
|
||||
{
|
||||
id: LORA_WIDGET_MAX_VISIBLE_SETTING_ID,
|
||||
name: "Node 2.0: Maximum visible LoRA entries",
|
||||
type: "slider",
|
||||
attrs: {
|
||||
min: 3,
|
||||
max: 50,
|
||||
step: 1,
|
||||
},
|
||||
defaultValue: LORA_WIDGET_MAX_VISIBLE_DEFAULT,
|
||||
tooltip: "When using Node 2.0 rendering, limit the loras widget height to show at most this many entries (default: 12). Excess entries are accessible via scrollbar.",
|
||||
category: ["LoRA Manager", "LoRA Widget", "Max Visible"],
|
||||
},
|
||||
],
|
||||
async setup() {
|
||||
await loadWorkflowOptions();
|
||||
@@ -591,5 +549,4 @@ export {
|
||||
getUsageStatisticsPreference,
|
||||
getNewTabTemplatePreference,
|
||||
getStrengthStepPreference,
|
||||
getLoraWidgetMaxVisibleLoras,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
|
||||
import { copyToClipboard } from "./loras_widget_utils.js";
|
||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||
|
||||
const MIN_HEIGHT = 150;
|
||||
const GROUP_EDITOR_ID = "lm-trigger-group-editor";
|
||||
@@ -696,6 +697,16 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
outline: "none",
|
||||
});
|
||||
|
||||
// Set a fixed minimum height so the node has a reasonable starting size.
|
||||
// Adding or removing tags does NOT change the node size — the container
|
||||
// scrolls when content exceeds the allocated space.
|
||||
ensureLmStyles();
|
||||
container.style.setProperty("--comfy-widget-min-height", `${MIN_HEIGHT}px`);
|
||||
|
||||
if (typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode) {
|
||||
container.classList.add("lm-vue-node");
|
||||
}
|
||||
|
||||
const initialTagsData = opts?.defaultVal || [];
|
||||
|
||||
function renderSimpleTag(tagData, index, widget, showStrengthInfo) {
|
||||
|
||||
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.autocomplete-text-widget[data-v-8555b560] {
|
||||
.autocomplete-text-widget[data-v-3f3d7a1a] {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-wrapper[data-v-8555b560] {
|
||||
.input-wrapper[data-v-3f3d7a1a] {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||
.text-input[data-v-8555b560] {
|
||||
.text-input[data-v-3f3d7a1a] {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background-color: var(--comfy-input-bg, #222);
|
||||
@@ -2141,16 +2141,18 @@ to { transform: rotate(360deg);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 2px 2px 24px 2px; /* Reserve bottom space for clear button */
|
||||
resize: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--comfy-textarea-font-size, 10px);
|
||||
font-family: monospace;
|
||||
/* resize:none set here (0,2,0). Overridden to vertical in app mode
|
||||
by the :global(.\\[\\&_textarea\\]\\:resize-y) .text-input rule below. */
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
||||
.text-input.vue-dom-mode[data-v-8555b560] {
|
||||
.text-input.vue-dom-mode[data-v-3f3d7a1a] {
|
||||
background-color: var(--color-charcoal-400, #313235);
|
||||
color: #fff;
|
||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||
@@ -2159,12 +2161,12 @@ to { transform: rotate(360deg);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.text-input[data-v-8555b560]:focus {
|
||||
.text-input[data-v-3f3d7a1a]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Clear button styles */
|
||||
.clear-button[data-v-8555b560] {
|
||||
.clear-button[data-v-3f3d7a1a] {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px; /* Changed from top to bottom */
|
||||
@@ -2187,33 +2189,39 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Show clear button when hovering over input wrapper */
|
||||
.input-wrapper:hover .clear-button[data-v-8555b560] {
|
||||
.input-wrapper:hover .clear-button[data-v-3f3d7a1a] {
|
||||
opacity: 0.7;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.clear-button[data-v-8555b560]:hover {
|
||||
.clear-button[data-v-3f3d7a1a]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
}
|
||||
.clear-button svg[data-v-8555b560] {
|
||||
.clear-button svg[data-v-3f3d7a1a] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-8555b560] {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a] {
|
||||
right: 8px;
|
||||
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-8555b560]:hover {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a]:hover {
|
||||
background: oklch(62% 0.18 25);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-8555b560] {
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-3f3d7a1a] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
|
||||
[data-testid="app-mode-widget-item"] textarea,
|
||||
[data-testid="builder-widget-item"] textarea {
|
||||
resize: vertical !important;
|
||||
}`));
|
||||
document.head.appendChild(elementStyle);
|
||||
}
|
||||
@@ -14952,7 +14960,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-8555b560"]]);
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-3f3d7a1a"]]);
|
||||
function createVueWidgetCleanup(vueApp, onCleanup) {
|
||||
let didUnmount = false;
|
||||
return () => {
|
||||
@@ -15718,19 +15726,13 @@ function normalizeAutocompleteWidgetValues(node, info) {
|
||||
info.widgets_values = repairedValues;
|
||||
}
|
||||
}
|
||||
function applyAutocompleteTextLayoutFix(widget, container, isVueMode) {
|
||||
function applyAutocompleteTextLayoutFix(widget, _container, isVueMode) {
|
||||
if (isVueMode) {
|
||||
widget.computeLayoutSize = void 0;
|
||||
widget.computeSize = (width) => [width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4];
|
||||
if (container) {
|
||||
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT}px`;
|
||||
}
|
||||
} else {
|
||||
delete widget.computeLayoutSize;
|
||||
delete widget.computeSize;
|
||||
if (container) {
|
||||
container.style.minHeight = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
const initVueDomModeListener = () => {
|
||||
@@ -15875,8 +15877,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
|
||||
const appKey = instanceId;
|
||||
vueApps.set(appKey, vueApp);
|
||||
if (maxHeight) {
|
||||
container.style.maxHeight = `${maxHeight}px`;
|
||||
container.style.minHeight = `${maxHeight}px`;
|
||||
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT}px`;
|
||||
}
|
||||
if (modelType === "loras") {
|
||||
applyAutocompleteTextLayoutFix(
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,10 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
|
||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback } from "./utils.js";
|
||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
|
||||
const LORA_NODE_CLASSES = new Set([
|
||||
"Lora Loader (LoraManager)",
|
||||
"Lora Stacker (LoraManager)",
|
||||
@@ -79,22 +81,77 @@ app.registerExtension({
|
||||
|
||||
setup() {
|
||||
ensureLmStyles();
|
||||
this._log("extension initialized, clientId=%s", api.clientId ?? api.initialClientId ?? "(pending)");
|
||||
|
||||
api.addEventListener("lora_registry_refresh", () => {
|
||||
this.refreshRegistry();
|
||||
this.refreshRegistry(true);
|
||||
});
|
||||
|
||||
api.addEventListener("lm_widget_update", (event) => {
|
||||
this.applyWidgetUpdate(event?.detail ?? {});
|
||||
});
|
||||
|
||||
// React to marker changes from the Node Marker extension
|
||||
window.addEventListener("lm_marker_changed", () => {
|
||||
this.refreshRegistry();
|
||||
});
|
||||
|
||||
this._hookGraphChanges();
|
||||
},
|
||||
|
||||
async refreshRegistry() {
|
||||
async afterConfigureGraph(_missingNodeTypes, _app) {
|
||||
this._log("afterConfigureGraph: workflow loaded (%s missing types)", _missingNodeTypes?.length ?? 0);
|
||||
await this.refreshRegistry();
|
||||
},
|
||||
|
||||
_hookGraphChanges() {
|
||||
const graph = app.graph;
|
||||
if (!graph) {
|
||||
this._log("app.graph not available, skipping proactive hooks");
|
||||
return;
|
||||
}
|
||||
|
||||
let hooksInstalled = 0;
|
||||
|
||||
const scheduleRefresh = (source) => {
|
||||
if (this._debounceTimer != null) {
|
||||
clearTimeout(this._debounceTimer);
|
||||
}
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._debounceTimer = null;
|
||||
this.refreshRegistry();
|
||||
}, DEBOUNCE_DELAY);
|
||||
};
|
||||
|
||||
try {
|
||||
chainCallback(graph, "onNodeAdded", () => scheduleRefresh("onNodeAdded"));
|
||||
chainCallback(graph, "onNodeRemoved", () => scheduleRefresh("onNodeRemoved"));
|
||||
hooksInstalled += 2;
|
||||
} catch (e) {
|
||||
this._log("failed to chain LiteGraph hooks: %s", e.message);
|
||||
}
|
||||
|
||||
if (typeof api.addEventListener === "function") {
|
||||
try {
|
||||
api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
|
||||
hooksInstalled += 1;
|
||||
} catch (_e) {
|
||||
// graphChanged may not be available on older ComfyUI versions
|
||||
}
|
||||
}
|
||||
|
||||
this._log("%s proactive hooks installed on graph", hooksInstalled);
|
||||
},
|
||||
|
||||
_log(format, ...args) {
|
||||
const ts = new Date().toISOString().slice(11, 23);
|
||||
let msg = format;
|
||||
for (const arg of args) {
|
||||
msg = msg.replace(/%s/g, String(arg));
|
||||
}
|
||||
console.debug(`[LM:Registry ${ts}] ${msg}`);
|
||||
},
|
||||
|
||||
async refreshRegistry(force = false) {
|
||||
try {
|
||||
const workflowNodes = [];
|
||||
const nodeEntries = getAllGraphNodes(app.graph);
|
||||
@@ -115,7 +172,6 @@ app.registerExtension({
|
||||
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
||||
const markerRole = node.properties?.lm_marker_role ?? null;
|
||||
|
||||
// Skip nodes with no relevant capability UNLESS they are marked
|
||||
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
|
||||
continue;
|
||||
}
|
||||
@@ -146,6 +202,19 @@ app.registerExtension({
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = api.clientId ?? api.initialClientId ?? "";
|
||||
|
||||
// Content-based dedup: skip POST if identical to last sent payload,
|
||||
// unless forced (e.g. responding to a lora_registry_refresh WS message
|
||||
// where the backend explicitly requests a re-registration).
|
||||
const fingerprint = JSON.stringify(
|
||||
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}`).sort()
|
||||
);
|
||||
if (!force && fingerprint === this._lastFingerprint) {
|
||||
return;
|
||||
}
|
||||
this._lastFingerprint = fingerprint;
|
||||
|
||||
const response = await fetch("/api/lm/register-nodes", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -153,7 +222,7 @@ app.registerExtension({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nodes: workflowNodes,
|
||||
client_id: api.clientId ?? api.initialClientId ?? "",
|
||||
client_id: clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user