Compare commits

...

22 Commits

Author SHA1 Message Date
Will Miao bf6a614e0d chore(release): bump version to v1.1.7 2026-07-13 22:18:16 +08:00
Will Miao feab01cd9c fix(preview): hide license icons for models without CivitAI metadata 2026-07-13 19:49:10 +08:00
Will Miao 966024e534 fix(registry): force re-registration on WS refresh to prevent timeout, demote empty-registry log to debug
- workflow_registry.js: add force param to refreshRegistry(), bypass fingerprint
  dedup when responding to lora_registry_refresh WS message. Without this, the
  backend's wait_for_all() times out after 0.5s because the frontend skips the
  register-nodes POST when the workflow fingerprint hasn't changed (common after
  ComfyUI restart with an empty or unchanged workflow).
- misc_handlers.py: demote 'No nodes registered after refresh' from WARNING to
  DEBUG — empty workflows are a normal operational state, not a warning-worthy
  condition.
2026-07-13 19:10:48 +08:00
Will Miao 2018722cc8 fix(registry): handle compound subgraph node IDs, add proactive node push from graph hooks
- Handle compound node IDs (e.g. "252:0") from expanded group subgraphs
  to fix 400 Bad Request on workflows with group nodes
- Frontend proactively pushes node data via afterConfigureGraph and
  LiteGraph hooks (onNodeAdded/onNodeRemoved/graphChanged), eliminating
  WebSocket round-trip latency for most "Send to Workflow" operations
- Add content-fingerprint dedup to skip duplicate register-nodes POSTs
- Fast-path cache returns immediately when tabs are registered (including
  0-node registrations), avoiding unnecessary WS refresh cycles
- Distinguish "Empty Registry" from other errors in standalone UI toast
- Reduce WS refresh timeout 2s→0.5s, add cooldown and lock to prevent
  concurrent refresh storms
- All [LM:Registry] logs at DEBUG level
2026-07-13 18:02:26 +08:00
Will Miao 9d85c2a44a fix(ui): prevent tags widget from auto-resizing in Vue mode when tags change 2026-07-13 14:55:40 +08:00
Will Miao 03dd047e62 fix(download): return 200 instead of 500 when user cancels download 2026-07-13 11:47:48 +08:00
Will Miao 86b547c1e0 fix(locales): add missing downloadStopped key to toast.downloads section 2026-07-13 11:35:48 +08:00
Will Miao bab9752c8b fix(download): close modal before progress overlay and fix downloadId ReferenceError on cancel 2026-07-13 11:29:47 +08:00
Will Miao 774cc1be86 fix(download): use file ID for exact match, add debug logging for multi-file selection (#1023)
- Frontend: send file.id in file_params, use null instead of hardcoded defaults
- Backend: priority matching (ID exact → primary → lenient metadata)
- Lenient metadata: only compare fields present on both sides (fixes GGUF size mismatch)
- Add debug logs at key points: entry, file_params received, match result, anomaly signals
2026-07-13 11:15:03 +08:00
Will Miao 234b73c8a2 feat(ui): add cancel button to download progress modal 2026-07-13 09:40:53 +08:00
Will Miao abd06c48f4 fix(settings): reject checkpoints↔unet path overlap in extra folder paths with inline error UI
Changes:
- Backend: _validate_folder_paths() now checks checkpoints↔unet overlap
  within the same library using os.path.realpath() for symlink resolution
- Backend: set() calls _validate_folder_paths() for both folder_paths and
  extra_folder_paths before writing
- Backend: extracted _normalize_path_set() helper to eliminate duplicated
  normalization logic
- Frontend: inline error display with red border + error message below the
  conflicting input, no save triggered
- Frontend: path normalization (strip trailing slash, lowercase) in pre-check
  to reduce false negatives vs backend realpath
- Frontend: asymmetric error UX — message only on the user-edited side,
  red border on the pre-existing conflict side
- CSS: has-error styles with hardcoded rgba fallback for older browsers
- i18n: checkpointUnetOverlap + checkpointUnetOverlapInline keys added to
  all 10 locale files
2026-07-13 08:22:40 +08:00
Will Miao 6ca411e4e4 fix(ui): make loras widget fixed-size with user-controlled node resize
Remove dynamic height calculation that auto-resized the node when
LoRAs are added or removed. The widget now stays at the size the user
sets via the node resize handle, scrolling when content overflows.

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