mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 03:31:17 -03:00
Compare commits
22 Commits
v1.0.7
...
26be187d42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26be187d42 | ||
|
|
d7caa1fa47 | ||
|
|
2629fcce23 | ||
|
|
438e7d07b9 | ||
|
|
e9932ea870 | ||
|
|
5dd8b96422 | ||
|
|
5e1cf68bbd | ||
|
|
1044fa3c83 | ||
|
|
397892bb7f | ||
|
|
f105500740 | ||
|
|
806555cf06 | ||
|
|
5cd7204101 | ||
|
|
3b602a3698 | ||
|
|
15dfaed462 | ||
|
|
0e51851025 | ||
|
|
0d0f4defca | ||
|
|
818fa34a48 | ||
|
|
78303b2a5e | ||
|
|
9ce56dd40c | ||
|
|
33e5f3d85d | ||
|
|
031d5e4f40 | ||
|
|
4ff5774e34 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ model_cache/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.codex
|
||||
.omo
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||
"noTags": "Keine Tags",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
|
||||
"loraSyntaxFormat": "LoRA-Syntaxformat",
|
||||
"loraSyntaxFormatHelp": "LoRA-Syntaxformat. Der vollständige Pfad enthält den Unterordnerpfad (<lora:style/anime/x:1.0>) für verlustfreie Modellauflösung. Legacy verwendet nur den Dateinamen (<lora:x:1.0>) — A1111-Konvention, kann bei doppelten Dateinamen in verschiedenen Ordnern zu Mehrdeutigkeiten führen.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Vollständiger Pfad (Unterordner/Name)",
|
||||
"legacy": "Legacy A1111 (nur Name)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "Handlungsbedarf",
|
||||
"error": "Aktion erforderlich"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Erneut ausführen",
|
||||
"exportBundle": "Paket exportieren"
|
||||
"exportBundle": "Paket exportieren",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Dateinamenskonflikte auflösen",
|
||||
"message": "Umbenennen durch Anhängen eines 4-stelligen Hashs an jeden doppelten Dateinamen.",
|
||||
"note": "Dieser Vorgang benennt Dateien auf der Festplatte um. Modellreferenzen in vorhandenen Workflows müssen möglicherweise aktualisiert werden, wenn Sie das A1111-Syntaxformat verwenden.",
|
||||
"detail": "Beispiel: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Benennt <strong>{count}</strong> Datei(en) in <strong>{groups}</strong> Duplikatgruppe(n) um",
|
||||
"confirm": "Dateien umbenennen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Anwendungs-Update erkannt",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||
"noTags": "No tags",
|
||||
"autoTags": "Auto Tags",
|
||||
"noBaseModelMatches": "No base models match the current search.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||
"loraSyntaxFormat": "LoRA Syntax Format",
|
||||
"loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path (<lora:style/anime/x:1.0>) for lossless model resolution. Legacy uses filename only (<lora:x:1.0>) — A1111 convention, may be ambiguous with duplicate filenames across folders.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Full path (subfolder/name)",
|
||||
"legacy": "Legacy A1111 (name only)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Enable Metadata Archive Database",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "Needs Attention",
|
||||
"error": "Action Required"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Run Again",
|
||||
"exportBundle": "Export Bundle"
|
||||
"exportBundle": "Export Bundle",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Failed to load diagnostics: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Resolve Filename Conflicts",
|
||||
"message": "Renaming by appending a 4-character hash to each duplicate filename.",
|
||||
"note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.",
|
||||
"detail": "Example: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Will rename <strong>{count}</strong> file(s) across <strong>{groups}</strong> duplicate group(s).",
|
||||
"confirm": "Rename Files",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Application Update Detected",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||
"noTags": "Sin etiquetas",
|
||||
"autoTags": "Etiquetas automáticas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
|
||||
"loraSyntaxFormat": "Formato de sintaxis LoRA",
|
||||
"loraSyntaxFormatHelp": "Formato de sintaxis LoRA. El formato completo incluye la ruta de la subcarpeta (<lora:style/anime/x:1.0>) para una resolución de modelo sin pérdidas. El formato heredado usa solo el nombre del archivo (<lora:x:1.0>) — convención A1111, puede ser ambiguo con nombres de archivo duplicados entre carpetas.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Ruta completa (subcarpeta/nombre)",
|
||||
"legacy": "A1111 heredado (solo nombre)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "Requiere atención",
|
||||
"error": "Se requiere acción"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Ejecutar de nuevo",
|
||||
"exportBundle": "Exportar paquete"
|
||||
"exportBundle": "Exportar paquete",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Resolver conflictos de nombres de archivo",
|
||||
"message": "Renombrar añadiendo un hash de 4 caracteres a cada nombre de archivo duplicado.",
|
||||
"note": "Esta operación renombra archivos en el disco. Es posible que las referencias a modelos en flujos de trabajo existentes deban actualizarse si usas el formato de sintaxis A1111.",
|
||||
"detail": "Ejemplo: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Renombrará <strong>{count}</strong> archivo(s) en <strong>{groups}</strong> grupo(s) de duplicados",
|
||||
"confirm": "Renombrar archivos",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Actualización de la aplicación detectada",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||
"noTags": "Aucun tag",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
|
||||
"loraSyntaxFormat": "Format de syntaxe LoRA",
|
||||
"loraSyntaxFormatHelp": "Format de syntaxe LoRA. Le format complet inclut le chemin du sous-dossier (<lora:style/anime/x:1.0>) pour une résolution de modèle sans perte. Le format hérité utilise uniquement le nom du fichier (<lora:x:1.0>) — convention A1111, peut être ambiguë en cas de noms de fichiers en double dans différents dossiers.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Chemin complet (sous-dossier/nom)",
|
||||
"legacy": "A1111 hérité (nom uniquement)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "Nécessite une attention",
|
||||
"error": "Action requise"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Relancer",
|
||||
"exportBundle": "Exporter le lot"
|
||||
"exportBundle": "Exporter le lot",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Résoudre les conflits de noms de fichiers",
|
||||
"message": "Renommer en ajoutant un hachage de 4 caractères à chaque nom de fichier en double.",
|
||||
"note": "Cette opération renomme les fichiers sur le disque. Les références de modèle dans les workflows existants peuvent nécessiter une mise à jour si vous utilisez le format de syntaxe A1111.",
|
||||
"detail": "Exemple : <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Renommera <strong>{count}</strong> fichier(s) dans <strong>{groups}</strong> groupe(s) de doublons",
|
||||
"confirm": "Renommer les fichiers",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Mise à jour de l'application détectée",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||
"noTags": "ללא תגיות",
|
||||
"autoTags": "תגיות אוטומטיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||
"loraSyntaxFormat": "פורמט תחביר LoRA",
|
||||
"loraSyntaxFormatHelp": "פורמט תחביר LoRA. נתיב מלא כולל תת-תיקייה (<lora:style/anime/x:1.0>) לפתרון מודל ללא אובדן. גרסה ישנה משתמשת בשם קובץ בלבד (<lora:x:1.0>) — מוסכמת A1111, עלולה להיות לא חד משמעית עם שמות קבצים כפולים בתיקיות שונות.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "נתיב מלא (תת-תיקייה/שם)",
|
||||
"legacy": "A1111 ישן (שם בלבד)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "דורש תשומת לב",
|
||||
"error": "נדרשת פעולה"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "הפעל שוב",
|
||||
"exportBundle": "ייצוא חבילה"
|
||||
"exportBundle": "ייצוא חבילה",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "פתור התנגשויות בשמות קבצים",
|
||||
"message": "שינוי שם על ידי הוספת האש באורך 4 תווים לכל שם קובץ כפול.",
|
||||
"note": "פעולה זו משנה שמות של קבצים בדיסק. ייתכן שיהיה צורך לעדכן הפניות למודלים בזרימות עבודה קיימות אם אתה משתמש בפורמט התחביר A1111.",
|
||||
"detail": "דוגמה: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "ישנה שם של <strong>{count}</strong> קבצים ב-<strong>{groups}</strong> קבוצות כפולות",
|
||||
"confirm": "שנה שמות קבצים",
|
||||
"cancel": "ביטול"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "זוהה עדכון יישום",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||
"noTags": "タグなし",
|
||||
"autoTags": "自動タグ",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||
"loraSyntaxFormat": "LoRA構文形式",
|
||||
"loraSyntaxFormatHelp": "LoRA構文形式。フルパスはサブフォルダパスを含み(<lora:style/anime/x:1.0>)、モデルをロスレスで解決します。レガシーはファイル名のみ(<lora:x:1.0>)— A1111規約ですが、フォルダ間でファイル名が重複する場合に曖昧になる可能性があります。",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "フルパス(サブフォルダ/名前)",
|
||||
"legacy": "レガシーA1111(名前のみ)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "要注意",
|
||||
"error": "対応が必要"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API キー"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "モデルキャッシュの健全性"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "ファイル名重複競合"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI バージョン"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "再実行",
|
||||
"exportBundle": "パッケージをエクスポート"
|
||||
"exportBundle": "パッケージをエクスポート",
|
||||
"open-settings": "設定を開く",
|
||||
"open-settings-syntax-format": "フルパス構文に切り替え",
|
||||
"repair-cache": "キャッシュを再構築",
|
||||
"resolve-filename-conflicts": "競合を解決",
|
||||
"reload-page": "UI をリロード"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "競合",
|
||||
"version": "バージョン"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "ファイル名の競合を解決",
|
||||
"message": "重複したファイル名に4文字のハッシュを追加してリネームします。",
|
||||
"note": "この操作はディスク上のファイルをリネームします。A1111 構文形式を使用している場合、既存のワークフロー内のモデル参照を更新する必要があるかもしれません。",
|
||||
"detail": "例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "<strong>{groups}</strong> 組の重複にわたって <strong>{count}</strong> 個のファイルをリネームします",
|
||||
"confirm": "ファイルをリネーム",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "アプリケーション更新が検出されました",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||
"noTags": "태그 없음",
|
||||
"autoTags": "자동 태그",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||
"loraSyntaxFormat": "LoRA 구문 형식",
|
||||
"loraSyntaxFormatHelp": "LoRA 구문 형식. 전체 경로는 하위 폴더 경로(<lora:style/anime/x:1.0>)를 포함하여 손실 없는 모델 해상도를 제공합니다. 레거시는 파일 이름만(<lora:x:1.0>) 사용 — A1111 규칙이지만, 폴더 간 파일명 중복 시 모호할 수 있습니다.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "전체 경로(하위 폴더/이름)",
|
||||
"legacy": "레거시 A1111(이름만)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "주의 필요",
|
||||
"error": "조치 필요"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 키"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "모델 캐시 상태"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "파일명 중복 충돌"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 버전"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "다시 실행",
|
||||
"exportBundle": "번들 내보내기"
|
||||
"exportBundle": "번들 내보내기",
|
||||
"open-settings": "설정 열기",
|
||||
"open-settings-syntax-format": "전체 경로 구문으로 전환",
|
||||
"repair-cache": "캐시 재구축",
|
||||
"resolve-filename-conflicts": "충돌 해결",
|
||||
"reload-page": "UI 새로고침"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "충돌",
|
||||
"version": "버전"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "진단 로드 실패: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "파일명 충돌 해결",
|
||||
"message": "중복 파일명에 4자리 해시를 추가하여 이름을 변경합니다.",
|
||||
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로우의 모델 참조를 업데이트해야 할 수 있습니다.",
|
||||
"detail": "예시: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "<strong>{groups}</strong>개 중복 그룹에서 <strong>{count}</strong>개 파일 이름을 변경합니다",
|
||||
"confirm": "파일 이름 변경",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "애플리케이션 업데이트 감지",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||
"noTags": "Без тегов",
|
||||
"autoTags": "Авто-теги",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||
"loraSyntaxFormat": "Формат синтаксиса LoRA",
|
||||
"loraSyntaxFormatHelp": "Формат синтаксиса LoRA. Полный путь включает подпапку (<lora:style/anime/x:1.0>) для безпотерьного разрешения модели. Устаревший использует только имя файла (<lora:x:1.0>) — соглашение A1111, может быть неоднозначным при дублировании имён файлов в разных папках.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Полный путь (подпапка/имя)",
|
||||
"legacy": "Устаревший A1111 (только имя)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Включить архив метаданных",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "Требует внимания",
|
||||
"error": "Требуется действие"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Запустить снова",
|
||||
"exportBundle": "Экспортировать пакет"
|
||||
"exportBundle": "Экспортировать пакет",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Разрешить конфликты имён файлов",
|
||||
"message": "Переименование с добавлением 4-символьного хеша к каждому дублирующемуся имени файла.",
|
||||
"note": "Эта операция переименовывает файлы на диске. Если вы используете синтаксис A1111, ссылки на модели в существующих рабочих процессах могут потребовать обновления.",
|
||||
"detail": "Пример: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Будет переименовано <strong>{count}</strong> файл(ов) в <strong>{groups}</strong> группе(ах) дубликатов",
|
||||
"confirm": "Переименовать файлы",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Обнаружено обновление приложения",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||
"noTags": "无标签",
|
||||
"autoTags": "自动标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||
"loraSyntaxFormat": "LoRA 语法格式",
|
||||
"loraSyntaxFormatHelp": "LoRA 语法格式。完整路径(Full)包含子文件夹路径 (<lora:style/anime/x:1.0>),解析精确无歧义。旧版(Legacy)仅使用文件名 (<lora:x:1.0>)——A1111 原始约定,同名文件跨文件夹时可能产生歧义。",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "完整路径(子文件夹/名称)",
|
||||
"legacy": "旧版 A1111(仅名称)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "启用元数据归档数据库",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "需要关注",
|
||||
"error": "需要处理"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 密钥"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型缓存健康状态"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "文件名重复冲突"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新检查",
|
||||
"exportBundle": "导出诊断包"
|
||||
"exportBundle": "导出诊断包",
|
||||
"open-settings": "打开设置",
|
||||
"open-settings-syntax-format": "切换为完整路径语法",
|
||||
"repair-cache": "重建缓存",
|
||||
"resolve-filename-conflicts": "解决冲突",
|
||||
"reload-page": "刷新 UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "冲突详情",
|
||||
"version": "版本信息"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "加载诊断结果失败:{message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "解决文件名冲突",
|
||||
"message": "通过在每个重复文件名后附加 4 位哈希值来重命名文件。",
|
||||
"note": "此操作会重命名磁盘上的文件。如果使用 A1111 语法格式,现有工作流中的模型引用可能需要更新。",
|
||||
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "将重命名 <strong>{count}</strong> 个文件(共 <strong>{groups}</strong> 组重复)",
|
||||
"confirm": "重命名文件",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "检测到应用更新",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||
"noTags": "無標籤",
|
||||
"autoTags": "自動標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||
"loraSyntaxFormat": "LoRA 語法格式",
|
||||
"loraSyntaxFormatHelp": "LoRA 語法格式。完整路徑(Full)包含子資料夾路徑 (<lora:style/anime/x:1.0>),解析精確無歧義。舊版(Legacy)僅使用檔名 (<lora:x:1.0>)——A1111 原始約定,同名檔案跨資料夾時可能產生歧義。",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "完整路徑(子資料夾/名稱)",
|
||||
"legacy": "舊版 A1111(僅名稱)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||
@@ -1921,9 +1929,32 @@
|
||||
"warning": "需要注意",
|
||||
"error": "需要處理"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 金鑰"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型快取健康狀態"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "檔案名稱重複衝突"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新執行",
|
||||
"exportBundle": "匯出套件"
|
||||
"exportBundle": "匯出套件",
|
||||
"open-settings": "開啟設定",
|
||||
"open-settings-syntax-format": "切換為完整路徑語法",
|
||||
"repair-cache": "重建快取",
|
||||
"resolve-filename-conflicts": "解決衝突",
|
||||
"reload-page": "重新載入 UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "衝突詳情",
|
||||
"version": "版本"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "載入診斷失敗:{message}",
|
||||
@@ -1935,6 +1966,15 @@
|
||||
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "解決檔案名稱衝突",
|
||||
"message": "通過在每個重複檔案名稱後附加 4 位元哈希值來重新命名檔案。",
|
||||
"note": "此操作會重新命名磁碟上的檔案。如果使用 A1111 語法格式,現有工作流程中的模型參考可能需要更新。",
|
||||
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "將重新命名 <strong>{count}</strong> 個檔案(共 <strong>{groups}</strong> 組重複)",
|
||||
"confirm": "重新命名檔案",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "偵測到應用程式更新",
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import (
|
||||
FlexibleOptionalInputType,
|
||||
any_type,
|
||||
apply_lora_syntax_format,
|
||||
detect_nunchaku_model_kind,
|
||||
extract_lora_name,
|
||||
get_loras_list,
|
||||
@@ -52,7 +53,7 @@ def _collect_widget_entries(kwargs):
|
||||
for lora in get_loras_list(kwargs):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
lora_name = lora["name"]
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
model_strength = float(lora["strength"])
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
||||
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
||||
|
||||
import logging
|
||||
|
||||
@@ -48,7 +48,7 @@ class LoraStackerLM:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
lora_name = apply_lora_syntax_format(lora['name'])
|
||||
model_strength = float(lora['strength'])
|
||||
# Get clip strength - use model strength as default if not specified
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
@@ -44,11 +44,29 @@ import folder_paths # type: ignore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_lora_syntax_format():
|
||||
try:
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
return get_settings_manager().get("lora_syntax_format", "legacy")
|
||||
except Exception:
|
||||
return "legacy"
|
||||
|
||||
|
||||
def apply_lora_syntax_format(name):
|
||||
fmt = get_lora_syntax_format()
|
||||
if fmt == "legacy":
|
||||
return name.replace("\\", "/").rstrip("/").split("/")[-1]
|
||||
return name
|
||||
|
||||
|
||||
def extract_lora_name(lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
normalized = lora_path.replace("\\", "/")
|
||||
basename = os.path.basename(normalized)
|
||||
name_no_ext = os.path.splitext(basename)[0]
|
||||
dirname = os.path.dirname(normalized)
|
||||
if dirname and dirname not in (".", "/") and not normalized.startswith("/"):
|
||||
return apply_lora_syntax_format(f"{dirname}/{name_no_ext}")
|
||||
return apply_lora_syntax_format(name_no_ext)
|
||||
|
||||
|
||||
def get_loras_list(kwargs):
|
||||
|
||||
@@ -686,6 +686,9 @@ class DoctorHandler:
|
||||
)
|
||||
|
||||
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||
return web.json_response({"success": True, "renamed": [], "count": 0})
|
||||
|
||||
renamed: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
@@ -990,11 +993,29 @@ class DoctorHandler:
|
||||
}
|
||||
|
||||
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
||||
# When full path syntax is active, duplicate filenames across subfolders
|
||||
# are not ambiguous (<lora:subfolder/name:strength>), so skip the check.
|
||||
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||
return {
|
||||
"id": "filename_conflicts",
|
||||
"title": "Duplicate Filename Conflicts",
|
||||
"status": "ok",
|
||||
"summary": "Full path syntax is active — duplicate filenames across folders are not ambiguous.",
|
||||
"details": [],
|
||||
"actions": [],
|
||||
}
|
||||
|
||||
all_conflicts: list[dict[str, Any]] = []
|
||||
total_conflict_groups = 0
|
||||
total_conflict_files = 0
|
||||
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
# Duplicate filename detection targets LoRAs which use basename-only
|
||||
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||
# models via relative paths with extensions, so conflicts there would
|
||||
# be false positives.
|
||||
if model_type != "lora":
|
||||
continue
|
||||
try:
|
||||
scanner = await factory()
|
||||
hash_index = getattr(scanner, "_hash_index", None)
|
||||
@@ -1042,12 +1063,22 @@ class DoctorHandler:
|
||||
"total_conflict_files": total_conflict_files,
|
||||
}
|
||||
]
|
||||
for conflict in all_conflicts:
|
||||
|
||||
# Show at most 5 conflict groups inline; note any remainder.
|
||||
MAX_VISIBLE_CONFLICTS = 5
|
||||
visible_conflicts = all_conflicts[:MAX_VISIBLE_CONFLICTS]
|
||||
for conflict in visible_conflicts:
|
||||
details.append(
|
||||
f"[{conflict['label']}] '{conflict['filename']}' "
|
||||
f"'{conflict['filename']}' "
|
||||
f"found in {len(conflict['paths'])} locations"
|
||||
)
|
||||
|
||||
hidden_count = len(all_conflicts) - MAX_VISIBLE_CONFLICTS
|
||||
if hidden_count > 0:
|
||||
details.append(
|
||||
f"...and {hidden_count} more duplicate filename group(s)"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": "filename_conflicts",
|
||||
"title": "Duplicate Filename Conflicts",
|
||||
@@ -1058,7 +1089,11 @@ class DoctorHandler:
|
||||
{
|
||||
"id": "resolve-filename-conflicts",
|
||||
"label": "Resolve Conflicts",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "open-settings-syntax-format",
|
||||
"label": "Switch to Full Path Syntax",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -788,7 +788,7 @@ class ModelManagementHandler:
|
||||
|
||||
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
|
||||
|
||||
await self._metadata_sync.save_metadata_updates(
|
||||
updated_metadata = await self._metadata_sync.save_metadata_updates(
|
||||
file_path=file_path,
|
||||
updates=metadata_updates,
|
||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||
@@ -799,7 +799,12 @@ class ModelManagementHandler:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
await cache.resort()
|
||||
|
||||
return web.json_response({"success": True})
|
||||
from ...services.auto_tag_service import extract_auto_tags
|
||||
auto_tags = extract_auto_tags(updated_metadata)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "auto_tags": auto_tags}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
@@ -816,14 +821,16 @@ class ModelManagementHandler:
|
||||
if not isinstance(new_tags, list):
|
||||
return web.Response(text="Tags must be a list", status=400)
|
||||
|
||||
tags = await self._tag_update_service.add_tags(
|
||||
tags, auto_tags = await self._tag_update_service.add_tags(
|
||||
file_path=file_path,
|
||||
new_tags=new_tags,
|
||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||
update_cache=self._service.scanner.update_single_model_cache,
|
||||
)
|
||||
|
||||
return web.json_response({"success": True, "tags": tags})
|
||||
return web.json_response(
|
||||
{"success": True, "tags": tags, "auto_tags": auto_tags}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
@@ -1170,6 +1177,12 @@ class ModelQueryHandler:
|
||||
|
||||
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
settings = get_settings_manager()
|
||||
if settings.get("lora_syntax_format", "legacy") == "full":
|
||||
return web.json_response(
|
||||
{"success": True, "conflicts": [], "count": 0}
|
||||
)
|
||||
|
||||
duplicates = self._service.find_duplicate_filenames()
|
||||
result = []
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,6 +13,12 @@ from ...config import config as global_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK_SIZE = 256 * 1024 # 256 KB
|
||||
|
||||
# Video file extensions that bypass native sendfile on Windows
|
||||
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
||||
_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"})
|
||||
|
||||
|
||||
class PreviewHandler:
|
||||
"""Serve preview assets for the active library at request time."""
|
||||
@@ -48,8 +55,51 @@ class PreviewHandler:
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
raise web.HTTPNotFound(text="Preview file not found")
|
||||
|
||||
# Video files: stream manually to avoid Windows native sendfile crash.
|
||||
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
|
||||
# which breaks when the client disconnects mid-transfer — this happens
|
||||
# constantly when users scroll through a gallery of animated previews.
|
||||
suffix = resolved.suffix.lower()
|
||||
if suffix in _VIDEO_EXTENSIONS:
|
||||
return await self._stream_file(request, resolved)
|
||||
|
||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
||||
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||
|
||||
async def _stream_file(
|
||||
self, request: web.Request, path: Path
|
||||
) -> web.StreamResponse:
|
||||
"""Stream a file chunk-by-chunk, bypassing native sendfile.
|
||||
|
||||
This avoids the Windows IOCP ``_sendfile_native`` crash that occurs
|
||||
when the client disconnects during a large file transfer.
|
||||
"""
|
||||
content_type, _ = mimetypes.guess_type(str(path))
|
||||
if content_type is None:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
file_size = path.stat().st_size
|
||||
resp = web.StreamResponse()
|
||||
resp.content_type = content_type
|
||||
resp.content_length = file_size
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
await resp.write(chunk)
|
||||
except (ConnectionResetError, ConnectionAbortedError):
|
||||
# Client disconnected during streaming — expected when scrolling
|
||||
# rapidly through a library with animated previews.
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug("I/O error streaming preview %s: %s", path, exc)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
__all__ = ["PreviewHandler"]
|
||||
|
||||
@@ -76,46 +76,64 @@ def _collect_sources(model_data: Dict) -> List[str]:
|
||||
def extract_auto_tags(model_data: Dict) -> List[str]:
|
||||
"""Extract auto-detected tags from model metadata.
|
||||
|
||||
Matches predefined patterns against filename, base_model, and
|
||||
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
|
||||
Uses a two-layer approach:
|
||||
Layer 1 — Regex-based detection against filename, base_model, and
|
||||
CivitAI version name.
|
||||
Layer 2 — Merge in any user-defined tags that overlap with known
|
||||
auto-tag categories. This provides a manual fallback when
|
||||
auto-detection fails (e.g. "I2V HN" or unlabeled models).
|
||||
|
||||
HIGH/LOW tags are only returned when the base_model indicates a Wan
|
||||
family model — no other model architecture uses this distinction.
|
||||
|
||||
Args:
|
||||
model_data: Model metadata dict with keys:
|
||||
file_name, base_model, civitai (with optional 'name' field).
|
||||
file_name, base_model, civitai (with optional 'name' field),
|
||||
tags (user-defined tag list, used as fallback).
|
||||
|
||||
Returns:
|
||||
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||
"""
|
||||
sources = _collect_sources(model_data)
|
||||
if not sources:
|
||||
return []
|
||||
|
||||
base_model = model_data.get("base_model", "")
|
||||
is_wan = "wan" in base_model.lower()
|
||||
|
||||
found: Set[str] = set()
|
||||
|
||||
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||
if label in ("HIGH", "LOW"):
|
||||
if not is_wan:
|
||||
continue
|
||||
# Use case-insensitive character class + case-sensitive boundary,
|
||||
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||
# Boundary: not followed by lowercase letter (= word has ended).
|
||||
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||
if label == "LOW":
|
||||
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||
# ── Layer 1: regex-based detection ────────────────────────────
|
||||
if sources:
|
||||
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||
if label in ("HIGH", "LOW"):
|
||||
if not is_wan:
|
||||
continue
|
||||
# Use case-insensitive character class + case-sensitive boundary,
|
||||
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||
# Boundary: not followed by lowercase letter (= word has ended).
|
||||
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||
if label == "LOW":
|
||||
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
for source in sources:
|
||||
if regex.search(source):
|
||||
found.add(label)
|
||||
break
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
for source in sources:
|
||||
if regex.search(source):
|
||||
found.add(label)
|
||||
break
|
||||
|
||||
# ── Layer 2: user-defined tags as manual fallback ─────────────
|
||||
# When auto-detection fails (abbreviated names like "Hi"/"Lo",
|
||||
# "I2V HN", or unlabeled models), users can add canonical tags
|
||||
# (HIGH, LOW, I2V, etc.) to the model's regular tags for correct
|
||||
# badge display and filtering. Matching is case-insensitive so
|
||||
# "high"/"High"/"HIGH" all resolve to the canonical label.
|
||||
user_tags = model_data.get("tags")
|
||||
if user_tags:
|
||||
label_map = {label.lower(): label for label in AUTO_TAG_CATEGORIES}
|
||||
for t in user_tags:
|
||||
canonical = label_map.get(t.lower())
|
||||
if canonical:
|
||||
found.add(canonical)
|
||||
|
||||
return sorted(found)
|
||||
|
||||
@@ -870,22 +870,75 @@ class BaseModelService(ABC):
|
||||
"""Get the static preview URL for a model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
name_normalized = model_name.replace("\\", "/")
|
||||
name_no_ext = name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if name_no_ext.lower().endswith(ext):
|
||||
name_no_ext = name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in name_no_ext
|
||||
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model["file_name"] == model_name:
|
||||
file_name = model.get("file_name", "")
|
||||
folder = model.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||
preview_url = model.get("preview_url")
|
||||
if preview_url:
|
||||
from ..config import config
|
||||
|
||||
return config.get_preview_static_url(preview_url)
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = model
|
||||
elif best_fallback is None:
|
||||
best_fallback = model
|
||||
|
||||
if best_fallback:
|
||||
preview_url = best_fallback.get("preview_url")
|
||||
if preview_url:
|
||||
from ..config import config
|
||||
|
||||
return config.get_preview_static_url(preview_url)
|
||||
|
||||
return "/loras_static/images/no-preview.png"
|
||||
|
||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||
"""Get the Civitai URL for a model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
name_normalized = model_name.replace("\\", "/")
|
||||
name_no_ext = name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if name_no_ext.lower().endswith(ext):
|
||||
name_no_ext = name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in name_no_ext
|
||||
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model["file_name"] == model_name:
|
||||
file_name = model.get("file_name", "")
|
||||
folder = model.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||
civitai_data = model.get("civitai", {})
|
||||
model_id = civitai_data.get("modelId")
|
||||
version_id = civitai_data.get("id")
|
||||
@@ -904,6 +957,27 @@ class BaseModelService(ABC):
|
||||
"version_id": str(version_id) if version_id else None,
|
||||
}
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = model
|
||||
elif best_fallback is None:
|
||||
best_fallback = model
|
||||
|
||||
if best_fallback:
|
||||
civitai_data = best_fallback.get("civitai", {})
|
||||
model_id = civitai_data.get("modelId")
|
||||
if model_id:
|
||||
version_id = civitai_data.get("id")
|
||||
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||
civitai_url = build_civitai_model_page_url(
|
||||
model_id, version_id, host=civitai_host
|
||||
)
|
||||
return {
|
||||
"civitai_url": civitai_url,
|
||||
"model_id": str(model_id),
|
||||
"version_id": str(version_id) if version_id else None,
|
||||
}
|
||||
|
||||
return {"civitai_url": None, "model_id": None, "version_id": None}
|
||||
|
||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||
|
||||
@@ -201,6 +201,29 @@ class CivitaiClient:
|
||||
|
||||
return _from_value(payload)
|
||||
|
||||
@staticmethod
|
||||
def _is_transient_server_error(message: str) -> bool:
|
||||
"""Return True when the message indicates a transient upstream failure.
|
||||
|
||||
Recognises Cloudflare 524, generic 5xx, and connectivity-level flakiness
|
||||
that should not be treated as a permanent failure.
|
||||
"""
|
||||
normalized = message.lower()
|
||||
if "status 5" in normalized or "status 524" in normalized:
|
||||
return True
|
||||
if any(
|
||||
keyword in normalized
|
||||
for keyword in (
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"temporary failure",
|
||||
"name resolution",
|
||||
"connection closed",
|
||||
)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
@@ -223,6 +246,13 @@ class CivitaiClient:
|
||||
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||
return None
|
||||
if message:
|
||||
if self._is_transient_server_error(message):
|
||||
logger.info(
|
||||
"Transient server error for model %s: %s",
|
||||
model_id,
|
||||
message,
|
||||
)
|
||||
return None
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
except RateLimitError:
|
||||
@@ -532,6 +562,13 @@ class CivitaiClient:
|
||||
if not success:
|
||||
if is_expected_offline_error(result):
|
||||
return None
|
||||
if self._is_transient_server_error(str(result)):
|
||||
logger.info(
|
||||
"Transient server error fetching image info for ID %s: %s",
|
||||
image_id,
|
||||
result,
|
||||
)
|
||||
return None
|
||||
logger.error(
|
||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||
image_id,
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -2239,8 +2240,11 @@ class DownloadManager:
|
||||
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
# Update size to actual downloaded file size
|
||||
entry.size = os.path.getsize(file_path)
|
||||
# Use SHA256 from API metadata (already set in from_civitai_info)
|
||||
# Do not recalculate to avoid blocking during ComfyUI execution
|
||||
# Compute SHA256 locally when the API response didn't include it
|
||||
if not entry.sha256:
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
if sha256:
|
||||
entry.sha256 = sha256.lower()
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
@@ -312,8 +312,23 @@ class LoraService(BaseModelService):
|
||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||
|
||||
fn_normalized = filename.replace("\\", "/")
|
||||
fn_no_ext = fn_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if fn_no_ext.lower().endswith(ext):
|
||||
fn_no_ext = fn_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
for lora in cache.raw_data if cache else []:
|
||||
if lora.get("file_name") == filename:
|
||||
file_name = lora.get("file_name", "")
|
||||
folder = lora.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
if fn_no_ext in (file_name_no_ext, path_name):
|
||||
return lora
|
||||
|
||||
return None
|
||||
@@ -401,7 +416,10 @@ class LoraService(BaseModelService):
|
||||
locked_loras = locked_loras[:target_count]
|
||||
|
||||
# Filter out locked LoRAs from available pool
|
||||
locked_names = {lora["name"] for lora in locked_loras}
|
||||
locked_names = {
|
||||
os.path.basename(lora["name"]) if "/" in str(lora.get("name", "")) else lora["name"]
|
||||
for lora in locked_loras
|
||||
}
|
||||
available_pool = [
|
||||
l for l in available_loras if l["file_name"] not in locked_names
|
||||
]
|
||||
@@ -456,7 +474,7 @@ class LoraService(BaseModelService):
|
||||
|
||||
result_loras.append(
|
||||
{
|
||||
"name": lora["file_name"],
|
||||
"name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||
"strength": model_str,
|
||||
"clipStrength": clip_str,
|
||||
"active": True,
|
||||
@@ -672,8 +690,9 @@ class LoraService(BaseModelService):
|
||||
# Return minimal data needed for cycling
|
||||
return [
|
||||
{
|
||||
"file_name": lora["file_name"],
|
||||
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||
"model_name": lora.get("model_name", lora["file_name"]),
|
||||
"folder": lora.get("folder", ""),
|
||||
}
|
||||
for lora in available_loras
|
||||
]
|
||||
|
||||
@@ -209,7 +209,9 @@ class ModelHashIndex:
|
||||
return self._filename_to_hash.get(filename)
|
||||
|
||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||
"""Get hash for a filename without extension"""
|
||||
"""Get hash for a filename (bare basename or path-prefixed name)"""
|
||||
if "/" in filename or "\\" in filename:
|
||||
filename = os.path.splitext(os.path.basename(filename.replace("\\", "/")))[0]
|
||||
return self._filename_to_hash.get(filename)
|
||||
|
||||
def clear(self) -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set,
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..config import config
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
@@ -1067,6 +1067,19 @@ class ModelScanner:
|
||||
|
||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||
|
||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
||||
if not model_data.get('sha256') and file_path:
|
||||
try:
|
||||
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
if sha256:
|
||||
model_data['sha256'] = sha256.lower()
|
||||
if isinstance(metadata, BaseModelMetadata):
|
||||
metadata.sha256 = sha256.lower()
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
|
||||
|
||||
# Skip excluded models
|
||||
if model_data.get('exclude', False):
|
||||
excluded_models.append(model_data['file_path'])
|
||||
@@ -1101,7 +1114,15 @@ class ModelScanner:
|
||||
|
||||
def _log_duplicate_filename_summary(self) -> None:
|
||||
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||
if self._hash_index is None:
|
||||
# Duplicate filename detection is only relevant for LoRAs, which use
|
||||
# basename-only syntax (<lora:name:strength>). Checkpoints and embeddings
|
||||
# use full relative paths for resolution, so conflicts are not ambiguous.
|
||||
if self._hash_index is None or self.model_type != "lora":
|
||||
return
|
||||
|
||||
# When full path syntax is active, duplicate filenames across subfolders
|
||||
# are fully qualified, so there is no ambiguity — skip the warning.
|
||||
if get_settings_manager().get("lora_syntax_format", "legacy") == "full":
|
||||
return
|
||||
|
||||
duplicates = self._hash_index.get_duplicate_filenames()
|
||||
@@ -1473,6 +1494,15 @@ class ModelScanner:
|
||||
file_path_override=normalized_new_path,
|
||||
)
|
||||
|
||||
# Ensure sha256 is populated even when metadata doesn't have it
|
||||
if not cache_entry.get('sha256') and normalized_new_path and os.path.exists(normalized_new_path):
|
||||
try:
|
||||
sha256 = await calculate_sha256(normalized_new_path)
|
||||
if sha256:
|
||||
cache_entry['sha256'] = sha256.lower()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compute SHA256 for {normalized_new_path}: {e}")
|
||||
|
||||
if recalculate_type:
|
||||
cache_entry = self.adjust_cached_entry(cache_entry)
|
||||
|
||||
@@ -1572,12 +1602,39 @@ class ModelScanner:
|
||||
"""Get model information by name"""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
|
||||
name_normalized = name.replace("\\", "/")
|
||||
name_no_ext = name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if name_no_ext.lower().endswith(ext):
|
||||
name_no_ext = name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in name_no_ext
|
||||
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model.get("file_name") == name:
|
||||
file_name = model.get("file_name", "")
|
||||
folder = model.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||
return model
|
||||
|
||||
return None
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = model
|
||||
elif best_fallback is None:
|
||||
best_fallback = model
|
||||
|
||||
return best_fallback
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -1000,12 +1000,11 @@ class ModelUpdateService:
|
||||
fallback_error_message = str(exc) or "resource not found"
|
||||
mark_model_as_ignored = True
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logger.error(
|
||||
logger.warning(
|
||||
"Failed to fetch versions for model %s (%s): %s",
|
||||
model_id,
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
fallback_error_message = str(exc)
|
||||
if response is not None:
|
||||
|
||||
@@ -2517,6 +2517,7 @@ class RecipeScanner:
|
||||
continue
|
||||
|
||||
file_name = None
|
||||
folder = ""
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if (
|
||||
hash_value
|
||||
@@ -2526,6 +2527,11 @@ class RecipeScanner:
|
||||
file_path = self._lora_scanner._hash_index.get_path(hash_value)
|
||||
if file_path:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if lora_cache is not None:
|
||||
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||
if cached_lora.get("file_path") == file_path:
|
||||
folder = cached_lora.get("folder", "")
|
||||
break
|
||||
|
||||
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
|
||||
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||
@@ -2540,13 +2546,16 @@ class RecipeScanner:
|
||||
file_name = os.path.splitext(os.path.basename(cached_path))[
|
||||
0
|
||||
]
|
||||
folder = cached_lora.get("folder", "")
|
||||
break
|
||||
|
||||
if not file_name:
|
||||
file_name = lora.get("file_name", "unknown-lora")
|
||||
folder = lora.get("folder", "")
|
||||
|
||||
lora_name = f"{folder}/{file_name}" if folder else file_name
|
||||
strength = lora.get("strength", 1.0)
|
||||
syntax_parts.append(f"<lora:{file_name}:{strength}>")
|
||||
syntax_parts.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
return syntax_parts
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"compact_mode": False,
|
||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||
"model_name_display": "model_name",
|
||||
"lora_syntax_format": "legacy",
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"show_version_on_card": True,
|
||||
"update_flag_strategy": "same_base",
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
|
||||
|
||||
class TagUpdateService:
|
||||
@@ -20,9 +22,8 @@ class TagUpdateService:
|
||||
new_tags: Sequence[str],
|
||||
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||
) -> List[str]:
|
||||
"""Add tags to a metadata entry while keeping case-insensitive uniqueness."""
|
||||
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||
base, _ = os.path.splitext(file_path)
|
||||
metadata_path = f"{base}.metadata.json"
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
@@ -44,5 +45,6 @@ class TagUpdateService:
|
||||
await self._metadata_manager.save_metadata(file_path, metadata)
|
||||
await update_cache(file_path, file_path, metadata)
|
||||
|
||||
return existing_tags
|
||||
auto_tags = extract_auto_tags(metadata)
|
||||
return existing_tags, auto_tags
|
||||
|
||||
|
||||
@@ -239,9 +239,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
|
||||
normalized_values.add(normalized)
|
||||
|
||||
has_sell = "sell" in normalized_values
|
||||
has_rent = has_sell or "rent" in normalized_values
|
||||
has_rentcivit = has_rent or "rentcivit" in normalized_values
|
||||
has_image = has_sell or "image" in normalized_values
|
||||
has_rent = "rent" in normalized_values
|
||||
has_rentcivit = "rentcivit" in normalized_values
|
||||
has_image = "image" in normalized_values
|
||||
|
||||
commercial_bits = (
|
||||
(1 if has_sell else 0) << 3
|
||||
|
||||
@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if lora_name_no_ext.lower().endswith(ext):
|
||||
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in lora_name_no_ext
|
||||
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get("file_name") == lora_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
# Check all lora roots including extra paths
|
||||
all_roots = list(config.loras_roots or []) + list(
|
||||
config.extra_loras_roots or []
|
||||
file_name = item.get("file_name", "")
|
||||
folder = item.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if lora_name_no_ext not in (file_name_no_ext, path_name):
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = item
|
||||
elif best_fallback is None:
|
||||
best_fallback = item
|
||||
continue
|
||||
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
all_roots = list(config.loras_roots or []) + list(
|
||||
config.extra_loras_roots or []
|
||||
)
|
||||
for root in all_roots:
|
||||
root = root.replace(os.sep, "/")
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
for root in all_roots:
|
||||
root = root.replace(os.sep, "/")
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = (
|
||||
civitai.get("trainedWords", []) if civitai else []
|
||||
)
|
||||
return relative_path, trigger_words
|
||||
# If not found in any root, return path with trigger words from cache
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
trigger_words = (
|
||||
civitai.get("trainedWords", []) if civitai else []
|
||||
)
|
||||
return relative_path, trigger_words
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
if best_fallback:
|
||||
file_path = best_fallback.get("file_path")
|
||||
if file_path:
|
||||
civitai = best_fallback.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
return lora_name, []
|
||||
|
||||
try:
|
||||
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if lora_name_no_ext.lower().endswith(ext):
|
||||
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in lora_name_no_ext
|
||||
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get("file_name") == lora_name:
|
||||
file_name = item.get("file_name", "")
|
||||
folder = item.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if lora_name_no_ext == file_name_no_ext:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
# Return absolute path directly
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
if lora_name_no_ext == path_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = item
|
||||
elif best_fallback is None:
|
||||
best_fallback = item
|
||||
|
||||
if best_fallback:
|
||||
file_path = best_fallback.get("file_path")
|
||||
if file_path:
|
||||
civitai = best_fallback.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
return lora_name, []
|
||||
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,39 @@
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .resolve-conflicts-detail {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .resolve-conflicts-detail code {
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .resolve-conflicts-impact {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
margin: var(--space-2) 0;
|
||||
color: var(--text-color);
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-model-info,
|
||||
.exclude-model-info {
|
||||
/* Update info display styling */
|
||||
|
||||
@@ -1369,3 +1369,14 @@ input:checked + .toggle-slider:before {
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Highlight animation for setting items targeted from Doctor actions */
|
||||
@keyframes settings-highlight-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(from var(--lora-accent) r g b / 0.2); }
|
||||
}
|
||||
|
||||
.settings-setting-highlight {
|
||||
animation: settings-highlight-pulse 1.5s ease-in-out 3;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
@@ -422,8 +422,12 @@ export class BaseModelApiClient {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, data);
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
state.virtualScroller.updateSingleItem(filePath, {
|
||||
...data,
|
||||
auto_tags: result.auto_tags,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
@@ -448,7 +452,10 @@ export class BaseModelApiClient {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.tags) {
|
||||
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
|
||||
state.virtualScroller.updateSingleItem(filePath, {
|
||||
tags: result.tags,
|
||||
auto_tags: result.auto_tags,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -166,7 +166,9 @@ async function toggleFavorite(card) {
|
||||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
||||
const folder = card.dataset.folder || '';
|
||||
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||||
const loraSyntax = buildLoraSyntax(loraName, usageTips);
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
const modelPath = card.dataset.filepath;
|
||||
|
||||
@@ -274,7 +274,17 @@ async function saveTags() {
|
||||
|
||||
const filePath = editBtn.dataset.filePath;
|
||||
const tagElements = document.querySelectorAll('.metadata-item');
|
||||
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||
|
||||
// Flush uncommitted input as a tag so it's not silently lost on save
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
if (tagInput) {
|
||||
const pendingTag = tagInput.value.trim().toLowerCase();
|
||||
if (pendingTag && !tags.includes(pendingTag)) {
|
||||
tags.push(pendingTag);
|
||||
}
|
||||
tagInput.value = '';
|
||||
}
|
||||
|
||||
// Get original tags to compare
|
||||
const originalTagElements = document.querySelectorAll('.tooltip-tag');
|
||||
@@ -465,6 +475,7 @@ function setupTagInput() {
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
|
||||
if (tagInput) {
|
||||
tagInput.focus();
|
||||
tagInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
@@ -74,7 +74,7 @@ export class BulkManager {
|
||||
unfavorite: true
|
||||
},
|
||||
recipes: {
|
||||
addTags: false,
|
||||
addTags: true,
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: false,
|
||||
@@ -785,6 +785,7 @@ export class BulkManager {
|
||||
// Setup tag input behavior
|
||||
const tagInput = document.querySelector('.bulk-metadata-input');
|
||||
if (tagInput) {
|
||||
tagInput.focus();
|
||||
tagInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -1008,7 +1009,17 @@ export class BulkManager {
|
||||
|
||||
async saveBulkTags(mode = 'append') {
|
||||
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
|
||||
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||
|
||||
// Flush uncommitted input as a tag so it's not silently lost on save
|
||||
const tagInput = document.querySelector('.bulk-metadata-input');
|
||||
if (tagInput) {
|
||||
const pendingTag = tagInput.value.trim().toLowerCase();
|
||||
if (pendingTag && !tags.includes(pendingTag)) {
|
||||
tags.push(pendingTag);
|
||||
}
|
||||
tagInput.value = '';
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
showToast('toast.models.noTagsToAdd', {}, 'warning');
|
||||
@@ -1032,6 +1043,8 @@ export class BulkManager {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
const isRecipes = state.currentPageType === 'recipes';
|
||||
|
||||
// Add or replace tags for each selected model based on mode
|
||||
for (const filePath of filePaths) {
|
||||
if (cancelled) {
|
||||
@@ -1039,7 +1052,9 @@ export class BulkManager {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
if (mode === 'replace') {
|
||||
if (isRecipes) {
|
||||
await this._saveRecipeTags(filePath, tags, mode);
|
||||
} else if (mode === 'replace') {
|
||||
await apiClient.saveModelMetadata(filePath, { tags: tags });
|
||||
} else {
|
||||
await apiClient.addTags(filePath, { tags: tags });
|
||||
@@ -1078,6 +1093,35 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _saveRecipeTags(filePath, newTags, mode) {
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
if (!recipeId) throw new Error('Unable to determine recipe ID');
|
||||
|
||||
let finalTags = newTags;
|
||||
if (mode === 'append') {
|
||||
const recipeItem = state.virtualScroller?.items?.find(
|
||||
item => item.file_path === filePath
|
||||
);
|
||||
const existingTags = recipeItem?.tags || [];
|
||||
finalTags = [...new Set([...existingTags, ...newTags])];
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/lm/recipe/${encodeURIComponent(recipeId)}/update`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags: finalTags }),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to update recipe tags');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, { tags: finalTags });
|
||||
}
|
||||
|
||||
cleanupBulkAddTagsModal() {
|
||||
// Clear tags container
|
||||
const tagsContainer = document.getElementById('bulkTagsItems');
|
||||
|
||||
@@ -225,6 +225,13 @@ export class DoctorManager {
|
||||
renderIssueCard(item) {
|
||||
const status = item.status || 'ok';
|
||||
const tagLabel = this.getStatusLabel(status);
|
||||
|
||||
const titleKey = `doctor.issues.${item.id || ''}.title`;
|
||||
const displayTitle = translate(titleKey, {}, item.title || '');
|
||||
|
||||
const summaryKey = `doctor.issues.${item.id || ''}.summary.${status}`;
|
||||
const displaySummary = translate(summaryKey, {}, item.summary || '');
|
||||
|
||||
const details = Array.isArray(item.details) ? item.details : [];
|
||||
const listItems = details
|
||||
.filter((detail) => typeof detail === 'string')
|
||||
@@ -235,19 +242,22 @@ export class DoctorManager {
|
||||
.map((detail) => this.renderInlineDetail(detail))
|
||||
.join('');
|
||||
const actions = (item.actions || [])
|
||||
.map((action) => `
|
||||
.map((action) => {
|
||||
const actionLabel = translate(`doctor.actions.${action.id}`, {}, action.label);
|
||||
return `
|
||||
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
|
||||
${escapeHtml(action.label)}
|
||||
${escapeHtml(actionLabel)}
|
||||
</button>
|
||||
`)
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="doctor-issue-card" data-status="${escapeHtml(status)}" data-issue-id="${escapeHtml(item.id || '')}">
|
||||
<div class="doctor-issue-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.title || '')}</h3>
|
||||
<p class="doctor-issue-summary">${escapeHtml(item.summary || '')}</p>
|
||||
<h3>${escapeHtml(displayTitle)}</h3>
|
||||
<p class="doctor-issue-summary">${escapeHtml(displaySummary)}</p>
|
||||
</div>
|
||||
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
|
||||
</div>
|
||||
@@ -262,7 +272,7 @@ export class DoctorManager {
|
||||
if (detail.conflict_groups || detail.total_conflict_files) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
|
||||
<strong>${escapeHtml(translate('doctor.labels.conflicts', {}, 'Conflicts'))}</strong>
|
||||
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -324,11 +334,42 @@ export class DoctorManager {
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'open-settings-syntax-format':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
// Switch to Interface section
|
||||
document.querySelectorAll('.settings-section').forEach((s) => s.classList.remove('active'));
|
||||
const interfaceSection = document.getElementById('section-interface');
|
||||
if (interfaceSection) {
|
||||
interfaceSection.classList.add('active');
|
||||
}
|
||||
document.querySelectorAll('.settings-nav-item').forEach((n) => n.classList.remove('active'));
|
||||
const interfaceNav = document.querySelector('.settings-nav-item[data-section="interface"]');
|
||||
if (interfaceNav) {
|
||||
interfaceNav.classList.add('active');
|
||||
}
|
||||
|
||||
// Focus and scroll to the LoRA Syntax Format dropdown
|
||||
const select = document.getElementById('loraSyntaxFormat');
|
||||
if (select) {
|
||||
select.focus();
|
||||
select.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Add temporary highlight animation
|
||||
const settingItem = select.closest('.setting-item');
|
||||
if (settingItem) {
|
||||
settingItem.classList.add('settings-setting-highlight');
|
||||
setTimeout(() => {
|
||||
settingItem.classList.remove('settings-setting-highlight');
|
||||
}, 4500);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'repair-cache':
|
||||
await this.repairCache();
|
||||
break;
|
||||
case 'resolve-filename-conflicts':
|
||||
await this.resolveFilenameConflicts();
|
||||
await this.promptResolveConflicts();
|
||||
break;
|
||||
case 'reload-page':
|
||||
this.reloadUi();
|
||||
@@ -358,6 +399,62 @@ export class DoctorManager {
|
||||
}
|
||||
}
|
||||
|
||||
_getConflictStats() {
|
||||
const conflict = (this.lastDiagnostics?.diagnostics || []).find(
|
||||
(d) => d.id === 'filename_conflicts'
|
||||
);
|
||||
if (!conflict || !Array.isArray(conflict.details)) {
|
||||
return { groups: 0, files: 0 };
|
||||
}
|
||||
const summary = conflict.details.find(
|
||||
(d) => d && typeof d === 'object' && d.conflict_groups !== undefined
|
||||
);
|
||||
return {
|
||||
groups: summary?.conflict_groups || 0,
|
||||
files: summary?.total_conflict_files || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async promptResolveConflicts() {
|
||||
const stats = this._getConflictStats();
|
||||
if (stats.groups === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailEl = document.getElementById('resolveConflictsDetail');
|
||||
if (detailEl) {
|
||||
detailEl.innerHTML = translate(
|
||||
'conflictConfirm.detail',
|
||||
{},
|
||||
'Example: <code>Add_Details_v1.2</code> \u2192 <code>Add_Details_v1.2-a3f7</code>'
|
||||
);
|
||||
}
|
||||
|
||||
const impactEl = document.getElementById('resolveConflictsImpact');
|
||||
if (impactEl) {
|
||||
impactEl.innerHTML = translate(
|
||||
'conflictConfirm.impact',
|
||||
{ count: stats.files, groups: stats.groups },
|
||||
`Will rename <strong>${stats.files}</strong> file(s) across <strong>${stats.groups}</strong> duplicate group(s).`
|
||||
);
|
||||
}
|
||||
|
||||
this._confirmResolveResolve = null;
|
||||
modalManager.showModal('resolveFilenameConflictsModal');
|
||||
return new Promise((resolve) => {
|
||||
this._confirmResolveResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async confirmResolveConflicts() {
|
||||
modalManager.closeModal('resolveFilenameConflictsModal');
|
||||
if (this._confirmResolveResolve) {
|
||||
this._confirmResolveResolve(true);
|
||||
this._confirmResolveResolve = null;
|
||||
}
|
||||
await this.resolveFilenameConflicts();
|
||||
}
|
||||
|
||||
async resolveFilenameConflicts() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
@@ -449,3 +546,8 @@ export class DoctorManager {
|
||||
}
|
||||
|
||||
export const doctorManager = new DoctorManager();
|
||||
|
||||
// Make available globally for HTML onclick handlers
|
||||
if (typeof window !== 'undefined') {
|
||||
window.doctorManager = doctorManager;
|
||||
}
|
||||
|
||||
@@ -316,6 +316,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register resolveFilenameConflictsModal
|
||||
const resolveFilenameConflictsModal = document.getElementById('resolveFilenameConflictsModal');
|
||||
if (resolveFilenameConflictsModal) {
|
||||
this.registerModal('resolveFilenameConflictsModal', {
|
||||
element: resolveFilenameConflictsModal,
|
||||
onClose: () => {
|
||||
this.getModal('resolveFilenameConflictsModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
@@ -396,7 +409,8 @@ export class ModalManager {
|
||||
id === "modelDuplicateDeleteModal" ||
|
||||
id === "clearCacheModal" ||
|
||||
id === "bulkDeleteModal" ||
|
||||
id === "checkUpdatesConfirmModal"
|
||||
id === "checkUpdatesConfirmModal" ||
|
||||
id === "resolveFilenameConflictsModal"
|
||||
) {
|
||||
modal.element.classList.add("show");
|
||||
} else {
|
||||
|
||||
@@ -295,6 +295,13 @@ export class SettingsManager {
|
||||
// Update state
|
||||
state.global.settings[settingKey] = value;
|
||||
|
||||
if (settingKey === 'lora_syntax_format') {
|
||||
try {
|
||||
localStorage.setItem('lm:lora-syntax-format-changed', Date.now().toString());
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isBackendSetting(settingKey)) {
|
||||
return;
|
||||
}
|
||||
@@ -949,6 +956,12 @@ export class SettingsManager {
|
||||
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
|
||||
}
|
||||
|
||||
// Set lora syntax format
|
||||
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
|
||||
if (loraSyntaxFormatSelect) {
|
||||
loraSyntaxFormatSelect.value = state.global.settings.lora_syntax_format || 'legacy';
|
||||
}
|
||||
|
||||
// Load metadata archive settings
|
||||
await this.loadMetadataArchiveSettings();
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
card_info_display: 'always',
|
||||
show_folder_sidebar: true,
|
||||
model_name_display: 'model_name',
|
||||
lora_syntax_format: 'legacy',
|
||||
model_card_footer_action: 'example_images',
|
||||
show_version_on_card: true,
|
||||
include_trigger_words: false,
|
||||
|
||||
@@ -420,17 +420,23 @@ export function getLoraStrengthsFromUsageTips(usageTips = {}) {
|
||||
export function buildLoraSyntax(fileName, usageTips = {}) {
|
||||
const { strength, hasStrength, clipStrength, hasClipStrength } = getLoraStrengthsFromUsageTips(usageTips);
|
||||
|
||||
const effectiveName = state.global.settings?.lora_syntax_format === 'legacy'
|
||||
? fileName.split('/').pop()
|
||||
: fileName;
|
||||
|
||||
if (hasClipStrength) {
|
||||
const modelStrength = hasStrength ? strength : 1;
|
||||
return `<lora:${fileName}:${modelStrength}:${clipStrength}>`;
|
||||
return `<lora:${effectiveName}:${modelStrength}:${clipStrength}>`;
|
||||
}
|
||||
|
||||
return `<lora:${fileName}:${strength}>`;
|
||||
return `<lora:${effectiveName}:${strength}>`;
|
||||
}
|
||||
|
||||
export function copyLoraSyntax(card) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || "{}");
|
||||
const baseSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
||||
const folder = card.dataset.folder || '';
|
||||
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||||
const baseSyntax = buildLoraSyntax(loraName, usageTips);
|
||||
|
||||
// Check if trigger words should be included
|
||||
const includeTriggerWords = state.global.settings.include_trigger_words;
|
||||
|
||||
@@ -218,10 +218,10 @@
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.license') }}</h4>
|
||||
<div class="filter-tags">
|
||||
<div class="filter-tag license-tag" data-license="noCredit">
|
||||
<div class="filter-tag license-tag" data-license="noCredit" title="{{ t('header.filter.noCreditRequiredTooltip') }}">
|
||||
{{ t('header.filter.noCreditRequired') }}
|
||||
</div>
|
||||
<div class="filter-tag license-tag" data-license="allowSelling">
|
||||
<div class="filter-tag license-tag" data-license="allowSelling" title="{{ t('header.filter.allowSellingGeneratedContentTooltip') }}">
|
||||
{{ t('header.filter.allowSellingGeneratedContent') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,4 +108,21 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolve Filename Conflicts Confirmation Modal -->
|
||||
<div id="resolveFilenameConflictsModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>{{ t('conflictConfirm.title') }}</h2>
|
||||
<p class="confirmation-message">{{ t('conflictConfirm.message') }}</p>
|
||||
<p class="resolve-conflicts-detail" id="resolveConflictsDetail"></p>
|
||||
<div class="resolve-conflicts-impact" id="resolveConflictsImpact"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="modalManager.closeModal('resolveFilenameConflictsModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" id="resolveConflictsConfirmBtn" onclick="doctorManager.confirmResolveConflicts()">
|
||||
<i class="fas fa-check"></i>
|
||||
{{ t('conflictConfirm.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,7 +536,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -595,6 +595,22 @@
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.misc') }}</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="loraSyntaxFormat">
|
||||
{{ t('settings.misc.loraSyntaxFormat') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.misc.loraSyntaxFormatHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="loraSyntaxFormat" onchange="settingsManager.saveSelectSetting('loraSyntaxFormat', 'lora_syntax_format')">
|
||||
<option value="full">{{ t('settings.misc.loraSyntaxFormatOptions.full') }}</option>
|
||||
<option value="legacy">{{ t('settings.misc.loraSyntaxFormatOptions.legacy') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
|
||||
@@ -65,32 +65,26 @@ async def test_allow_selling_filter():
|
||||
"""Test the allow selling generated content filtering logic."""
|
||||
service = DummyModelService()
|
||||
|
||||
# Create test data with different license flags
|
||||
# CommercialUse values are independent — Sell does NOT imply Image.
|
||||
test_data = [
|
||||
# Model allowing selling (contains Image in allowCommercialUse)
|
||||
{"file_path": "model1.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Image"]})},
|
||||
# Model not allowing selling (doesn't contain Image in allowCommercialUse)
|
||||
{"file_path": "model2.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["RentCivit"]})},
|
||||
# Model with default license flags (includes Sell by default, which implies Image)
|
||||
{"file_path": "model3.safetensors", "license_flags": build_license_flags(None)},
|
||||
# Model allowing selling (contains Sell in allowCommercialUse, which implies Image)
|
||||
{"file_path": "model4.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Sell"]})},
|
||||
# Model with empty allowCommercialUse (doesn't allow selling)
|
||||
{"file_path": "model5.safetensors", "license_flags": build_license_flags({"allowCommercialUse": []})},
|
||||
]
|
||||
|
||||
# Test allow_selling=True (should return models that allow selling - have Image permission)
|
||||
# Default and Sell permissions both include Image, so model3 and model4 will be included
|
||||
# Test allow_selling=True (should return only models with the Image permission)
|
||||
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=True)
|
||||
assert len(filtered) == 3 # model1, model3 (default includes Sell which implies Image), model4
|
||||
assert len(filtered) == 1 # only model1 has Image permission
|
||||
file_paths = {item["file_path"] for item in filtered}
|
||||
assert file_paths == {"model1.safetensors", "model3.safetensors", "model4.safetensors"}
|
||||
assert file_paths == {"model1.safetensors"}
|
||||
|
||||
# Test allow_selling=False (should return models that don't allow selling - don't have Image permission)
|
||||
# Test allow_selling=False (should return models without the Image permission)
|
||||
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=False)
|
||||
assert len(filtered) == 2 # model2 and model5
|
||||
assert len(filtered) == 4 # model2, model3, model4, model5
|
||||
file_paths = {item["file_path"] for item in filtered}
|
||||
assert file_paths == {"model2.safetensors", "model5.safetensors"}
|
||||
assert file_paths == {"model2.safetensors", "model3.safetensors", "model4.safetensors", "model5.safetensors"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -131,13 +131,12 @@ async def test_pool_filter_allow_selling_true(lora_service, sample_loras):
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
|
||||
# Should keep models with Image permission (allowSelling)
|
||||
# Models: no_credit_required_for_selling, credit_required_for_selling, default_license
|
||||
assert len(filtered) == 3
|
||||
# Sell alone does not imply Image, so default_license is excluded.
|
||||
assert len(filtered) == 2
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"no_credit_required_for_selling.safetensors",
|
||||
"credit_required_for_selling.safetensors",
|
||||
"default_license.safetensors",
|
||||
}
|
||||
|
||||
|
||||
@@ -178,12 +177,11 @@ async def test_pool_filter_both_license_filters(lora_service, sample_loras):
|
||||
# Should keep models where both conditions are met:
|
||||
# - allowNoCredit=True (no credit required)
|
||||
# - Image permission exists (allow selling)
|
||||
# Models: no_credit_required_for_selling, default_license
|
||||
assert len(filtered) == 2
|
||||
# default_license has ["Sell"] without Image, so it's excluded.
|
||||
assert len(filtered) == 1
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"no_credit_required_for_selling.safetensors",
|
||||
"default_license.safetensors",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -132,7 +132,8 @@ async def test_initialize_cache_populates_cache(tmp_path: Path):
|
||||
_normalize_path(tmp_path / "one.txt"),
|
||||
_normalize_path(tmp_path / "nested" / "two.txt"),
|
||||
}
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
|
||||
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {113}
|
||||
|
||||
assert scanner._hash_index.get_path("hash-one") == _normalize_path(tmp_path / "one.txt")
|
||||
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
|
||||
@@ -190,7 +191,8 @@ async def test_initialize_in_background_applies_scan_result(tmp_path: Path, monk
|
||||
_normalize_path(tmp_path / "one.txt"),
|
||||
_normalize_path(tmp_path / "nested" / "two.txt"),
|
||||
}
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
|
||||
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {113}
|
||||
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
|
||||
assert scanner._tags_count == {"alpha": 1, "beta": 1}
|
||||
assert scanner._excluded_models == [_normalize_path(tmp_path / "skip-file.txt")]
|
||||
@@ -636,6 +638,8 @@ async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplo
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
scanner = DummyScanner(root)
|
||||
# Duplicate filename detection is only active for LoRAs
|
||||
scanner.model_type = "lora"
|
||||
|
||||
# Simulate duplicate filenames in the hash index
|
||||
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
|
||||
@@ -646,7 +650,7 @@ async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplo
|
||||
assert len(caplog.records) >= 1
|
||||
log_msg = caplog.records[-1].message
|
||||
assert "Duplicate filename conflict detected" in log_msg
|
||||
assert "1 dummy filename(s)" in log_msg
|
||||
assert "1 lora filename(s)" in log_msg
|
||||
assert "2 files total" in log_msg
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ def test_tag_update_service_adds_unique_tags(tmp_path: Path) -> None:
|
||||
cache_updates.append(metadata)
|
||||
return True
|
||||
|
||||
tags = asyncio.run(
|
||||
tags, auto_tags = asyncio.run(
|
||||
service.add_tags(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
new_tags=["new", "existing"],
|
||||
@@ -265,5 +265,6 @@ def test_tag_update_service_adds_unique_tags(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
assert tags == ["existing", "new"]
|
||||
assert auto_tags == []
|
||||
assert manager.saved
|
||||
assert cache_updates
|
||||
|
||||
@@ -43,7 +43,7 @@ async def test_tag_update_service_handles_case_insensitive_tags(tmp_path: Path)
|
||||
return True
|
||||
|
||||
# Try to add "Test" (different case) - should not be added since "test" already exists
|
||||
tags = await service.add_tags(
|
||||
tags, auto_tags = await service.add_tags(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
new_tags=["Test"],
|
||||
metadata_loader=loader,
|
||||
@@ -52,6 +52,7 @@ async def test_tag_update_service_handles_case_insensitive_tags(tmp_path: Path)
|
||||
|
||||
# Should still only have "test" (lowercase) in the tags
|
||||
assert tags == ["test"]
|
||||
assert auto_tags == [] # no file_name/base_model in metadata, so no auto-detection
|
||||
assert len(manager.saved) == 1
|
||||
saved_metadata = manager.saved[0][1]
|
||||
assert saved_metadata["tags"] == ["test"]
|
||||
@@ -76,7 +77,7 @@ async def test_tag_update_service_adds_new_tags_in_lowercase(tmp_path: Path) ->
|
||||
return True
|
||||
|
||||
# Add new tags with mixed case
|
||||
tags = await service.add_tags(
|
||||
tags, auto_tags = await service.add_tags(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
new_tags=["NewTag", "ANOTHER_TAG"],
|
||||
metadata_loader=loader,
|
||||
@@ -87,6 +88,7 @@ async def test_tag_update_service_adds_new_tags_in_lowercase(tmp_path: Path) ->
|
||||
assert "existing" in tags
|
||||
assert "newtag" in tags
|
||||
assert "another_tag" in tags
|
||||
assert auto_tags == []
|
||||
assert len(manager.saved) == 1
|
||||
saved_metadata = manager.saved[0][1]
|
||||
assert "newtag" in saved_metadata["tags"]
|
||||
|
||||
@@ -126,6 +126,80 @@ class TestExtractAutoTags:
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
# ── Layer 2: user-defined tags as manual fallback ───────────
|
||||
|
||||
def test_user_tags_fallback_when_detection_fails(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "BOTH-v1.0",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "I2V", "T2V"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V", "T2V"}
|
||||
|
||||
def test_user_tags_augment_partial_detection(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_i2v_hn_v2",
|
||||
"base_model": "Wan 2.2 I2V",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_user_tags_non_auto_tag_ignored(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "model_v1",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "character", "style", "nsfw"],
|
||||
})
|
||||
assert set(result) == {"HIGH"}
|
||||
|
||||
def test_user_tags_overrides_non_wan_gate(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "flux_model_v1",
|
||||
"base_model": "Flux.1 D",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "LOW", "Turbo"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "LOW", "Turbo"}
|
||||
|
||||
def test_user_tags_no_duplication(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_i2v_high_v3",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "I2V"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_user_tags_lightning_turbo_manual(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "sdxl_model_v1",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
"tags": ["Lightning"],
|
||||
})
|
||||
assert set(result) == {"Lightning"}
|
||||
|
||||
def test_user_tags_case_insensitive_lowercase(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_masterpieces_v2",
|
||||
"base_model": "Wan Video 14B t2v",
|
||||
"civitai": {},
|
||||
"tags": ["high"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "T2V"}
|
||||
|
||||
def test_user_tags_case_insensitive_mixed(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "model_v1",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
"tags": ["lightning", "turbo", "i2v"],
|
||||
})
|
||||
assert set(result) == {"Lightning", "Turbo", "I2V"}
|
||||
|
||||
|
||||
class TestAutoTagCategories:
|
||||
def test_all_patterns_compile(self):
|
||||
|
||||
@@ -16,7 +16,9 @@ def test_resolve_license_payload_defaults():
|
||||
assert payload["allowDerivatives"] is True
|
||||
assert payload["allowDifferentLicense"] is True
|
||||
assert payload["allowCommercialUse"] == ["Sell"]
|
||||
assert flags == 127
|
||||
# Default ["Sell"] only sets the Sell bit (16), plus NoCredit (1),
|
||||
# Derivatives (32) and DifferentLicense (64) = 113.
|
||||
assert flags == 113
|
||||
|
||||
|
||||
def test_build_license_flags_custom_values():
|
||||
@@ -34,11 +36,10 @@ def test_build_license_flags_custom_values():
|
||||
assert payload["allowDifferentLicense"] is False
|
||||
|
||||
flags = build_license_flags(source)
|
||||
# Sell automatically enables all commercial bits including image.
|
||||
assert flags == 30
|
||||
assert flags == 18
|
||||
|
||||
|
||||
def test_build_license_flags_respects_commercial_hierarchy():
|
||||
def test_build_license_flags_independent_values():
|
||||
base = {
|
||||
"allowNoCredit": False,
|
||||
"allowDerivatives": False,
|
||||
@@ -46,14 +47,10 @@ def test_build_license_flags_respects_commercial_hierarchy():
|
||||
}
|
||||
|
||||
assert build_license_flags({**base, "allowCommercialUse": []}) == 0
|
||||
# Rent adds rent and rentcivit permissions.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 12
|
||||
# RentCivit alone should only set its own bit.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 8
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["RentCivit"]}) == 4
|
||||
# Image only toggles the image bit.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Image"]}) == 2
|
||||
# Sell forces all commercial bits regardless of image listing.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 30
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 16
|
||||
|
||||
|
||||
def test_build_license_flags_parses_aggregate_string():
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
import pytest
|
||||
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.utils import (
|
||||
calculate_recipe_fingerprint,
|
||||
calculate_relative_path_for_model,
|
||||
get_lora_info,
|
||||
get_lora_info_absolute,
|
||||
sanitize_folder_name,
|
||||
)
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self, items):
|
||||
self.raw_data = list(items)
|
||||
|
||||
|
||||
class _FakeScanner:
|
||||
def __init__(self, items):
|
||||
self._cache = _FakeCache(items)
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_lora_scanner(monkeypatch):
|
||||
def _setup(items):
|
||||
scanner = _FakeScanner(items)
|
||||
|
||||
async def get_scanner():
|
||||
return scanner
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", get_scanner)
|
||||
return scanner
|
||||
|
||||
return _setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_settings(monkeypatch):
|
||||
manager = get_settings_manager()
|
||||
@@ -114,3 +144,114 @@ def test_calculate_recipe_fingerprint_empty_input():
|
||||
)
|
||||
def test_sanitize_folder_name(original, expected):
|
||||
assert sanitize_folder_name(original) == expected
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_bare_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("mylora")
|
||||
|
||||
assert path == "/models/Lora/SDXL/mylora.safetensors"
|
||||
assert triggers == ["trigger1"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_with_path(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL/Styles", "file_path": "/models/Lora/SDXL/Styles/mylora.safetensors", "civitai": {"trainedWords": ["artistic"]}},
|
||||
{"file_name": "other", "folder": "", "file_path": "/models/Lora/other.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("SDXL/Styles/mylora")
|
||||
|
||||
assert path == "/models/Lora/SDXL/Styles/mylora.safetensors"
|
||||
assert triggers == ["artistic"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_path_fallback_to_basename(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "RenamedFolder", "file_path": "/models/Lora/RenamedFolder/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("OldFolder/mylora")
|
||||
|
||||
assert path == "/models/Lora/RenamedFolder/mylora.safetensors"
|
||||
assert triggers == ["trigger1"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_prefers_folder_match(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "V1", "file_path": "/models/Lora/V1/mylora.safetensors", "civitai": {"trainedWords": ["v1"]}},
|
||||
{"file_name": "mylora", "folder": "V2", "file_path": "/models/Lora/V2/mylora.safetensors", "civitai": {"trainedWords": ["v2"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("V2/mylora")
|
||||
|
||||
assert path == "/models/Lora/V2/mylora.safetensors"
|
||||
assert triggers == ["v2"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_no_folder_in_cache_no_path_in_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "", "file_path": "/models/Lora/mylora.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("mylora")
|
||||
|
||||
assert path == "/models/Lora/mylora.safetensors"
|
||||
assert triggers == []
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_strips_extension(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["hello"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("SDXL/mylora.safetensors")
|
||||
|
||||
assert path == "/models/Lora/SDXL/mylora.safetensors"
|
||||
assert triggers == ["hello"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_not_found_returns_original(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("nonexistent")
|
||||
|
||||
assert path == "nonexistent"
|
||||
assert triggers == []
|
||||
|
||||
|
||||
def test_get_lora_info_bare_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info("mylora")
|
||||
|
||||
assert triggers == ["trigger1"]
|
||||
|
||||
|
||||
def test_get_lora_info_with_path(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL/Styles", "file_path": "/models/Lora/SDXL/Styles/mylora.safetensors", "civitai": {"trainedWords": ["artistic"]}},
|
||||
{"file_name": "other", "folder": "", "file_path": "/models/Lora/other.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info("SDXL/Styles/mylora")
|
||||
|
||||
assert triggers == ["artistic"]
|
||||
|
||||
|
||||
def test_get_lora_info_not_found_returns_original(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info("nonexistent")
|
||||
|
||||
assert path == "nonexistent"
|
||||
assert triggers == []
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div class="section__toggles">
|
||||
<label class="toggle-item">
|
||||
<span class="toggle-item__label">No Credit Required</span>
|
||||
<span class="toggle-item__label" title="Use the model without crediting the creator">No Credit Required</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-switch"
|
||||
@@ -20,7 +20,7 @@
|
||||
</label>
|
||||
|
||||
<label class="toggle-item">
|
||||
<span class="toggle-item__label">Allow Selling</span>
|
||||
<span class="toggle-item__label" title="Allow selling generated images">Allow Selling</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-switch"
|
||||
|
||||
@@ -104,6 +104,66 @@ function removeLoraExtension(fileName = '') {
|
||||
return fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||
}
|
||||
|
||||
let _loraSyntaxFormatCache = null;
|
||||
let _loraSyntaxFormatRefreshPromise = null;
|
||||
|
||||
function _getLoraSyntaxFormat() {
|
||||
if (_loraSyntaxFormatCache !== null) {
|
||||
return _loraSyntaxFormatCache;
|
||||
}
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
async function _fetchLoraSyntaxFormat() {
|
||||
try {
|
||||
const response = await api.fetchApi('/lm/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
_loraSyntaxFormatCache = data.settings.lora_syntax_format || 'legacy';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
if (_loraSyntaxFormatCache === null) {
|
||||
_loraSyntaxFormatCache = 'legacy';
|
||||
}
|
||||
}
|
||||
|
||||
function _triggerBackgroundRefresh() {
|
||||
if (_loraSyntaxFormatRefreshPromise) {
|
||||
return;
|
||||
}
|
||||
_loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
|
||||
_loraSyntaxFormatRefreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshLoraSyntaxFormat() {
|
||||
await _fetchLoraSyntaxFormat();
|
||||
}
|
||||
|
||||
function _initLoraSyntaxFormat() {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
_initLoraSyntaxFormat();
|
||||
|
||||
function _initLoraSyntaxFormatReactive() {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'lm:lora-syntax-format-changed') {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
_initLoraSyntaxFormatReactive();
|
||||
|
||||
function parseSearchTokens(term = '') {
|
||||
const include = [];
|
||||
const exclude = [];
|
||||
@@ -226,7 +286,14 @@ const MODEL_BEHAVIORS = {
|
||||
}
|
||||
},
|
||||
async getInsertText(_instance, relativePath) {
|
||||
const fileName = removeLoraExtension(splitRelativePath(relativePath).fileName);
|
||||
const { directories, fileName } = splitRelativePath(relativePath);
|
||||
const baseName = removeLoraExtension(fileName);
|
||||
const folder = directories.length ? directories.join('/') + '/' : '';
|
||||
const loraName = folder + baseName;
|
||||
|
||||
const resultName = _getLoraSyntaxFormat() === 'legacy'
|
||||
? baseName
|
||||
: loraName;
|
||||
|
||||
let strength = 1.0;
|
||||
let hasStrength = false;
|
||||
@@ -262,9 +329,9 @@ const MODEL_BEHAVIORS = {
|
||||
}
|
||||
|
||||
if (clipStrength !== null) {
|
||||
return formatAutocompleteInsertion(`<lora:${fileName}:${strength}:${clipStrength}>`);
|
||||
return formatAutocompleteInsertion(`<lora:${resultName}:${strength}:${clipStrength}>`);
|
||||
}
|
||||
return formatAutocompleteInsertion(`<lora:${fileName}:${strength}>`);
|
||||
return formatAutocompleteInsertion(`<lora:${resultName}:${strength}>`);
|
||||
}
|
||||
},
|
||||
embeddings: {
|
||||
@@ -1430,6 +1497,11 @@ class AutoComplete {
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
// Prevent textarea from losing focus - same fix as createItemElement
|
||||
itemEl.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
itemEl.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index, { manual: true });
|
||||
});
|
||||
@@ -2158,6 +2230,16 @@ class AutoComplete {
|
||||
item.appendChild(nameSpan);
|
||||
}
|
||||
|
||||
// Prevent textarea from losing focus when clicking dropdown items.
|
||||
// Without this, the blur event fires before click, and the blur handler's
|
||||
// formatAutocompleteTextOnBlur() modifies the text and triggers hide()
|
||||
// via suppressAutocompleteOnce, removing this item from the DOM before
|
||||
// the click handler can execute. This specifically breaks the case where
|
||||
// the text has a comma not followed by a space (e.g. "<lora:X:1>,search").
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index, { manual: true });
|
||||
@@ -2745,4 +2827,4 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
export { AutoComplete };
|
||||
export { AutoComplete, refreshLoraSyntaxFormat };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
|
||||
import { copyToClipboard } from "./loras_widget_utils.js";
|
||||
|
||||
const MIN_HEIGHT = 150;
|
||||
const GROUP_EDITOR_ID = "lm-trigger-group-editor";
|
||||
@@ -568,6 +569,56 @@ function toggleGroupEditor(widget, index, anchorEl) {
|
||||
openGroupEditor(widget, index, anchorEl);
|
||||
}
|
||||
|
||||
function showTagContextMenu(event, tagData, index, widget, anchorEl) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
closeGroupEditor(widget);
|
||||
|
||||
const existingMenu = document.querySelector('.lm-lora-context-menu');
|
||||
if (existingMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'lm-lora-context-menu';
|
||||
menu.style.left = `${event.clientX}px`;
|
||||
menu.style.top = `${event.clientY}px`;
|
||||
|
||||
const copyOption = document.createElement('div');
|
||||
copyOption.className = 'lm-lora-menu-item';
|
||||
copyOption.innerHTML = `<span class="lm-lora-menu-item-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></span>Copy`;
|
||||
copyOption.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
copyToClipboard(tagData.text, 'Copied to clipboard');
|
||||
});
|
||||
menu.appendChild(copyOption);
|
||||
|
||||
if (isGroupTag(tagData) && Array.isArray(tagData.items) && tagData.items.length > 1) {
|
||||
const editOption = document.createElement('div');
|
||||
editOption.className = 'lm-lora-menu-item';
|
||||
editOption.innerHTML = `<span class="lm-lora-menu-item-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></span>Edit Group`;
|
||||
editOption.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
toggleGroupEditor(widget, index, anchorEl);
|
||||
renderGroupEditor(widget, tagData, index);
|
||||
});
|
||||
menu.appendChild(editOption);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const closeMenu = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||
}
|
||||
|
||||
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-tags-container";
|
||||
@@ -618,6 +669,10 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
});
|
||||
});
|
||||
|
||||
tagEl.addEventListener("contextmenu", (e) => {
|
||||
showTagContextMenu(e, tagData, index, widget, tagEl);
|
||||
});
|
||||
|
||||
if (showStrengthInfo) {
|
||||
tagEl.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -728,11 +783,13 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
};
|
||||
|
||||
editButton.addEventListener("click", openEditor);
|
||||
groupChip.addEventListener("contextmenu", openEditor);
|
||||
|
||||
groupChip.appendChild(editButton);
|
||||
}
|
||||
|
||||
groupChip.addEventListener("contextmenu", (e) => {
|
||||
showTagContextMenu(e, tagData, index, widget, groupChip);
|
||||
});
|
||||
|
||||
groupChip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (editButton && e.target === editButton) {
|
||||
@@ -773,6 +830,11 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
const existingMenu = document.querySelector('.lm-lora-context-menu');
|
||||
if (existingMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
const normalizedTags = Array.isArray(tagsData) ? tagsData : [];
|
||||
const showStrengthInfo = widget.allowStrengthAdjustment ?? allowStrengthAdjustment;
|
||||
const groupAnchors = new Map();
|
||||
|
||||
@@ -398,13 +398,13 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section[data-v-dea4adf6] {
|
||||
.section[data-v-07ddd3df] {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section__header[data-v-dea4adf6] {
|
||||
.section__header[data-v-07ddd3df] {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section__title[data-v-dea4adf6] {
|
||||
.section__title[data-v-07ddd3df] {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -412,21 +412,21 @@
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.section__toggles[data-v-dea4adf6] {
|
||||
.section__toggles[data-v-07ddd3df] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.toggle-item[data-v-dea4adf6] {
|
||||
.toggle-item[data-v-07ddd3df] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-item__label[data-v-dea4adf6] {
|
||||
.toggle-item__label[data-v-07ddd3df] {
|
||||
font-size: 12px;
|
||||
color: var(--fg-color, #fff);
|
||||
}
|
||||
.toggle-switch[data-v-dea4adf6] {
|
||||
.toggle-switch[data-v-07ddd3df] {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
@@ -435,7 +435,7 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-switch__track[data-v-dea4adf6] {
|
||||
.toggle-switch__track[data-v-07ddd3df] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
@@ -443,11 +443,11 @@
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.toggle-switch--active .toggle-switch__track[data-v-dea4adf6] {
|
||||
.toggle-switch--active .toggle-switch__track[data-v-07ddd3df] {
|
||||
background: rgba(66, 153, 225, 0.3);
|
||||
border-color: rgba(66, 153, 225, 0.6);
|
||||
}
|
||||
.toggle-switch__thumb[data-v-dea4adf6] {
|
||||
.toggle-switch__thumb[data-v-07ddd3df] {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 2px;
|
||||
@@ -458,12 +458,12 @@
|
||||
transition: all 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.toggle-switch--active .toggle-switch__thumb[data-v-dea4adf6] {
|
||||
.toggle-switch--active .toggle-switch__thumb[data-v-07ddd3df] {
|
||||
transform: translateX(16px);
|
||||
background: #4299e1;
|
||||
opacity: 1;
|
||||
}
|
||||
.toggle-switch:hover .toggle-switch__thumb[data-v-dea4adf6] {
|
||||
.toggle-switch:hover .toggle-switch__thumb[data-v-07ddd3df] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -2223,7 +2223,7 @@ to { transform: rotate(360deg);
|
||||
})();
|
||||
var _a;
|
||||
import { app as app$1 } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { api as api$1 } from "../../../scripts/api.js";
|
||||
/**
|
||||
* @vue/shared v3.5.26
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
@@ -11094,7 +11094,10 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
], -1)),
|
||||
createBaseVNode("div", _hoisted_2$e, [
|
||||
createBaseVNode("label", _hoisted_3$c, [
|
||||
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "toggle-item__label" }, "No Credit Required", -1)),
|
||||
_cache[3] || (_cache[3] = createBaseVNode("span", {
|
||||
class: "toggle-item__label",
|
||||
title: "Use the model without crediting the creator"
|
||||
}, "No Credit Required", -1)),
|
||||
createBaseVNode("button", {
|
||||
type: "button",
|
||||
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.noCreditRequired }]),
|
||||
@@ -11107,7 +11110,10 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
])], 10, _hoisted_4$a)
|
||||
]),
|
||||
createBaseVNode("label", _hoisted_5$8, [
|
||||
_cache[5] || (_cache[5] = createBaseVNode("span", { class: "toggle-item__label" }, "Allow Selling", -1)),
|
||||
_cache[5] || (_cache[5] = createBaseVNode("span", {
|
||||
class: "toggle-item__label",
|
||||
title: "Allow selling generated images"
|
||||
}, "Allow Selling", -1)),
|
||||
createBaseVNode("button", {
|
||||
type: "button",
|
||||
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.allowSelling }]),
|
||||
@@ -11124,7 +11130,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-dea4adf6"]]);
|
||||
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-07ddd3df"]]);
|
||||
const _hoisted_1$e = { class: "preview" };
|
||||
const _hoisted_2$d = { class: "preview__title" };
|
||||
const _hoisted_3$b = ["disabled"];
|
||||
@@ -15031,6 +15037,54 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
|
||||
};
|
||||
}
|
||||
const app = {};
|
||||
const api = {
|
||||
fetchApi: (...args) => fetch(...args),
|
||||
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
|
||||
removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler)
|
||||
};
|
||||
let _loraSyntaxFormatCache = null;
|
||||
let _loraSyntaxFormatRefreshPromise = null;
|
||||
async function _fetchLoraSyntaxFormat() {
|
||||
try {
|
||||
const response = await api.fetchApi("/lm/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
_loraSyntaxFormatCache = data.settings.lora_syntax_format || "legacy";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
if (_loraSyntaxFormatCache === null) {
|
||||
_loraSyntaxFormatCache = "legacy";
|
||||
}
|
||||
}
|
||||
function _triggerBackgroundRefresh() {
|
||||
if (_loraSyntaxFormatRefreshPromise) {
|
||||
return;
|
||||
}
|
||||
_loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
|
||||
_loraSyntaxFormatRefreshPromise = null;
|
||||
});
|
||||
}
|
||||
function _initLoraSyntaxFormat() {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
_initLoraSyntaxFormat();
|
||||
function _initLoraSyntaxFormatReactive() {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "lm:lora-syntax-format-changed") {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
_initLoraSyntaxFormatReactive();
|
||||
const ROOT_GRAPH_ID = "root";
|
||||
const LORA_PROVIDER_NODE_TYPES = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
@@ -15407,7 +15461,7 @@ function createLoraRandomizerWidget(node) {
|
||||
const vueApp = createApp(LoraRandomizerWidget, {
|
||||
widget,
|
||||
node,
|
||||
api
|
||||
api: api$1
|
||||
});
|
||||
vueApp.use(PrimeVue, {
|
||||
unstyled: true,
|
||||
@@ -15482,7 +15536,7 @@ function createLoraCyclerWidget(node) {
|
||||
const vueApp = createApp(LoraCyclerWidget, {
|
||||
widget,
|
||||
node,
|
||||
api
|
||||
api: api$1
|
||||
});
|
||||
vueApp.use(PrimeVue, {
|
||||
unstyled: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user