Compare commits

...

16 Commits

Author SHA1 Message Date
Will Miao
26be187d42 fix(i18n): translate remaining loraSyntaxFormat TODO keys across all locales
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-26 06:15:57 +08:00
Will Miao
d7caa1fa47 fix(license): remove cascading commercial-use bit encoding, clarify Allow Selling label (#941)
- _resolve_commercial_bits() no longer has Sell-implies-Image
  cascading; each CommercialUse value sets only its own bit,
  matching CivitAI's modern array-format API.
- Keep filter tag label as 'Allow Selling' for brevity; add
  title/tooltip 'Allow selling generated images' on hover.
- Same tooltip treatment for 'No Credit Required'.
- Add i18n keys for both tooltips across all 10 locales.
2026-05-26 06:02:17 +08:00
Will Miao
2629fcce23 fix(doctor): add i18n translations for check items, action buttons, and labels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-25 22:35:48 +08:00
Will Miao
438e7d07b9 fix(i18n): add missing conflictConfirm.detail and conflictConfirm.impact keys to all locales
These keys are referenced in DoctorManager.js via translate() calls but were never added to any locale file, causing the i18n regression test to fail.

Added to all 10 locales: en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-25 22:25:13 +08:00
Will Miao
e9932ea870 feat(tags): add right-click context menu with copy for trigger word tags
- Add showTagContextMenu() with Copy option for all tags,
  plus Edit Group for multi-item group tags
- Attach contextmenu listener to simple tags
- Move group tag contextmenu outside items.length > 1 guard so
  single-child groups also get the context menu (bugfix)
- Clean up hanging context menu on re-render
2026-05-25 22:16:54 +08:00
Will Miao
5dd8b96422 fix(autocomplete): reactively refresh lora syntax format cache on settings change (#917)
The autocomplete module cached the lora_syntax_format value at module load
but never updated it when the setting changed, causing autocomplete to
always insert legacy A1111 format even when 'full path' was configured.

- Expose refreshLoraSyntaxFormat() to re-fetch the setting from the API
- Listen for cross-tab 'storage' events to react to settings saved in
  the standalone web UI
- Listen for 'visibilitychange' to refresh when the user switches back
  to the ComfyUI tab
- Wire SettingsManager.saveSetting() to set a localStorage key when
  lora_syntax_format changes, triggering the storage event
2026-05-25 22:03:56 +08:00
Will Miao
5e1cf68bbd fix(settings): sync loraSyntaxFormat select value from state on modal open (#917)
was missing the line to set the
select element's value from ,
causing the dropdown to always show the first option ("Full Path")
when reopening the settings modal, regardless of the persisted value.
Runtime behavior was unaffected since  reads from
the state directly.
2026-05-25 21:35:15 +08:00
Will Miao
1044fa3c83 feat(doctor): improve duplicate filename conflict UX with confirm modal, syntax-format nav, and i18n
- Remove [LoRAs] prefix noise from conflict detail display
- Limit inline conflict groups to 5, show remainder count
- Add 'Switch to Full Path Syntax' action in conflict card
- Add confirmation modal before resolving conflicts (shows rename strategy)
- Register resolveFilenameConflictsModal in ModalManager (fix no-op showModal)
- Switch to Interface section and add highlight animation on syntax-format nav
- Sync and translate conflictConfirm strings across all 10 locales
2026-05-25 21:25:35 +08:00
Will Miao
397892bb7f fix(recipe): treat transient server errors (524/5xx) as non-fatal in image info fetch
Extend _is_transient_server_error() check introduced in 15dfaed4 to
get_image_info(), so Cloudflare 524 and generic 5xx errors during
remote recipe import are logged as info instead of error and do not
produce scary tracebacks.

Same pattern as get_model_versions() - transient upstream failures
return None gracefully rather than being logged as errors.
2026-05-25 08:35:35 +08:00
Will Miao
f105500740 feat(doctor): suppress duplicate filename warnings when full path syntax is active (#917) 2026-05-22 22:35:06 +08:00
Will Miao
806555cf06 fix(test): update autocomplete test expectations for legacy lora syntax format (#917) 2026-05-22 21:56:38 +08:00
Will Miao
5cd7204101 fix(autocomplete): prevent blur-on-click race condition causing dropped selection (#939)
Add mousedown(e.preventDefault()) on dropdown items to prevent the textarea blur event from firing before click. Without this, the blur handler's formatAutocompleteTextOnBlur() modifies text with unmatched commas (e.g. "<lora:X:1>,search") and triggers hide() via suppressAutocompleteOnce, removing the item from the DOM before the click handler can execute.

Fixes #939
2026-05-22 21:50:26 +08:00
Will Miao
3b602a3698 feat(lora): add lora_syntax_format setting for syntax version toggle (#917)
Adds lora_syntax_format setting (full/legacy) that controls whether <lora:...> syntax uses relative paths (full) or filename only (legacy). Default is legacy for backward compatibility with A1111 convention. The full path format (<lora:relative/path/filename:strength>) enables lossless model resolution across subfolders.

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-22 21:03:29 +08:00
Will Miao
15dfaed462 fix(api): treat transient server errors (524/5xx) as non-fatal in model updates (#935)
Teach CivitaiClient.get_model_versions() to recognise Cloudflare 524, generic
5xx, and connection-level errors as transient failures and return None
instead of raising RuntimeError, so a single upstream glitch does not
block the entire batch update or produce a scary traceback.

Also downgrade the generic except Exception log level in
ModelUpdateService._refresh_single_model() from error (with exc_info)
to warning (message only), since the full traceback is already logged
upstream in CivitaiClient.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-22 07:05:06 +08:00
Will Miao
0e51851025 fix(preview): stream video files manually to avoid Windows sendfile crash
aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based), which crashes with ov.getresult() when the client disconnects mid-transfer. This happens constantly when users scroll through a gallery of animated previews (video files like .mp4/.webm).

Detect video extensions and stream manually via StreamResponse + chunked reads instead, gracefully handling ConnectionResetError. Images continue using FileResponse (small files, sendfile works fine).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-21 09:12:10 +08:00
Will Miao
0d0f4defca feat(recipes): enable bulk Add Tags to Selected for recipes (#934)
- Set addTags: true in recipes bulk action config
- Add _saveRecipeTags() helper using recipe API endpoint
- Replace mode: saves tags array directly via PUT recipe/update
- Append mode: merges with existing tags from virtual scroller
- Shows bulk Add Tags modal & target menu item on recipes page
2026-05-20 23:14:38 +08:00
42 changed files with 1083 additions and 110 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "זוהה עדכון יישום",

View File

@@ -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": "アプリケーション更新が検出されました",

View File

@@ -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": "애플리케이션 업데이트 감지",

View File

@@ -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": "Обнаружено обновление приложения",

View File

@@ -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": "检测到应用更新",

View File

@@ -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": "偵測到應用程式更新",

View File

@@ -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)

View File

@@ -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))

View File

@@ -44,14 +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):
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 f"{dirname}/{name_no_ext}"
return name_no_ext
return apply_lora_syntax_format(f"{dirname}/{name_no_ext}")
return apply_lora_syntax_format(name_no_ext)
def get_loras_list(kwargs):

View File

@@ -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,6 +993,18 @@ 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
@@ -1048,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",
@@ -1064,7 +1089,11 @@ class DoctorHandler:
{
"id": "resolve-filename-conflicts",
"label": "Resolve Conflicts",
}
},
{
"id": "open-settings-syntax-format",
"label": "Switch to Full Path Syntax",
},
],
}

View File

@@ -1177,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()

View File

@@ -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"]

View File

@@ -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,

View File

@@ -1120,6 +1120,11 @@ class ModelScanner:
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()
if not duplicates:
return

View File

@@ -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:

View File

@@ -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",

View File

@@ -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

View File

@@ -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 */

View File

@@ -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);
}

View File

@@ -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,
@@ -1043,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) {
@@ -1050,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 });
@@ -1089,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');

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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,

View File

@@ -420,12 +420,16 @@ 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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -185,7 +185,7 @@ describe('AutoComplete widget interactions', () => {
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
);
expect(input.value).toContain('<lora:models/example:1.5:0.9>,');
expect(input.value).toContain('<lora:example:1.5:0.9>,');
expect(autoComplete.dropdown.style.display).toBe('none');
expect(input.focus).toHaveBeenCalled();
expect(input.setSelectionRange).toHaveBeenCalled();
@@ -1624,8 +1624,8 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('models/example.safetensors');
expect(input.value).toContain('<lora:models/example:1.2>');
expect(input.value).not.toContain('<lora:models/example:1.2>,');
expect(input.value).toContain('<lora:example:1.2>');
expect(input.value).not.toContain('<lora:example:1.2>,');
});
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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")]

View File

@@ -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():

View File

@@ -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"

View File

@@ -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 = [];
@@ -231,6 +291,10 @@ const MODEL_BEHAVIORS = {
const folder = directories.length ? directories.join('/') + '/' : '';
const loraName = folder + baseName;
const resultName = _getLoraSyntaxFormat() === 'legacy'
? baseName
: loraName;
let strength = 1.0;
let hasStrength = false;
let clipStrength = null;
@@ -265,9 +329,9 @@ const MODEL_BEHAVIORS = {
}
if (clipStrength !== null) {
return formatAutocompleteInsertion(`<lora:${loraName}:${strength}:${clipStrength}>`);
return formatAutocompleteInsertion(`<lora:${resultName}:${strength}:${clipStrength}>`);
}
return formatAutocompleteInsertion(`<lora:${loraName}:${strength}>`);
return formatAutocompleteInsertion(`<lora:${resultName}:${strength}>`);
}
},
embeddings: {
@@ -1433,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 });
});
@@ -2161,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 });
@@ -2748,4 +2827,4 @@ class AutoComplete {
}
}
export { AutoComplete };
export { AutoComplete, refreshLoraSyntaxFormat };

View File

@@ -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();

View File

@@ -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