Merge branch 'feature/workflow-management'

This commit is contained in:
Will Miao
2026-10-29 16:44:08 +08:00
88 changed files with 7514 additions and 82 deletions
+42
View File
@@ -239,6 +239,18 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> `toast.contextMenu.linkHfSuccess` (the toast now says "model source" instead of
> "HuggingFace", commit `5ab0e88ab`). No `[TODO: Translate]` placeholder remains.
> **Status (2026-10, workflow management):** the Workflows page (`workflows.*`) manages the
> ComfyUI workflow JSON files in their own library folders and shipped with the settings toggle
> and folder list (`settings.folderSettings.enableWorkflows*` / `.workflowFolders*`), the folder
> key label, the two folder-list nav paths and the initialization progress text — 41 keys
> `[TODO: Translate]` in all 9 locales, now translated (terminology in §2, "Workflows feature").
> `Workflow` reuses the term already fixed in §5 per locale: Latin in fr/de/es/ru/he (so
> `header.navigation.workflows` and `workflows.title` stay `Workflows` there), 工作流 in
> zh-CN/zh-TW, ワークフロー in ja, 워크플로 in ko. The three `workflows.noPaths.open*` buttons are
> byte-identical to `other.noPaths.open*` (same actions), and the nav-path hints reuse
> `other.disabled.hint`'s "Settings > Library" / `other.noPaths.descriptionStandalone`'s
> "Settings → Model Paths" renderings. No `[TODO: Translate]` placeholder remains.
---
## 1. Hard rules (do not violate)
@@ -928,6 +940,36 @@ Most of these strings are assembled from existing renderings rather than new ter
- `firstRun.language.label` reuses `common.language.select` (zh-CN keeps its 选择语言 verb
label rather than introducing a second rendering of "Language").
### Workflows feature (page, folders, restore/update actions)
The Workflows page (`workflows.*`) lists the ComfyUI workflow JSON files kept in their own
library folders. **Workflow** is the §5 term per locale and never becomes a different noun;
"workflow management" is the feature name, built from that noun plus the locale's management
word (`Verwaltung` / `gestion` / `gestión` / `управление` / `ניהול`).
| Term | Rendering |
|---|---|
| Workflows (page title / nav) | zh-CN 工作流 · zh-TW 工作流 · ja ワークフロー · ko 워크플로 · fr/de/es/ru/he Workflows |
| workflow (noun in prose) | zh-CN 工作流 · zh-TW 工作流 · ja ワークフロー · ko 워크플로 · fr workflow · de Workflow · es workflow · ru workflow · he workflow |
| Workflow Management (settings toggle) | zh-CN 工作流管理 · zh-TW 工作流管理 · ja ワークフロー管理 · ko 워크플로 관리 · fr Gestion des workflows · de Workflow-Verwaltung · es Gestión de workflows · ru Управление workflow · he ניהול workflow |
| Workflow Folders / Workflow Paths | zh-CN 工作流文件夹 / 工作流路径 · zh-TW 工作流資料夾 / 工作流路徑 · ja ワークフローフォルダー / ワークフローパス · ko 워크플로 폴더 / 워크플로 경로 · fr Dossiers de workflows / Chemins de workflows · de Workflow-Ordner / Workflow-Pfade · es Carpetas de workflows / Rutas de workflows · ru Папки workflow / Пути workflow · he תיקיות workflow / נתיבי workflow |
| Open in ComfyUI (card action and context menu) | zh-CN 在 ComfyUI 中打开 · zh-TW 在 ComfyUI 中開啟 · ja ComfyUIで開く · ko ComfyUI에서 열기 · fr Ouvrir dans ComfyUI · de In ComfyUI öffnen · es Abrir en ComfyUI · ru Открыть в ComfyUI · he פתח ב-ComfyUI |
| Update from ComfyUI | zh-CN 从 ComfyUI 更新 · zh-TW 從 ComfyUI 更新 · ja ComfyUIから更新 · ko ComfyUI에서 업데이트 · fr Mettre à jour depuis ComfyUI · de Von ComfyUI aktualisieren · es Actualizar desde ComfyUI · ru Обновить из ComfyUI · he עדכן מ-ComfyUI |
| Restore Downloaded Version | zh-CN 恢复已下载版本 · zh-TW 還原已下載版本 · ja ダウンロード済みバージョンを復元 · ko 다운로드한 버전 복원 · fr Restaurer la version téléchargée · de Heruntergeladene Version wiederherstellen · es Restaurar versión descargada · ru Восстановить загруженную версию · he שחזר את הגרסה שהורדה |
- "CivitAI Workflow resources" keeps `Workflow` in Latin even in the CJK locales: it names
CivitAI's resource type (the §2 "Other Models" rule for model-type names), not this page.
- `folder_paths.workflows` is a JSON key and stays byte-identical in every locale.
- `noPaths.descriptionStandalone` keeps the standalone split — **Settings → Model Paths** plus
a restart — while `.descriptionComfyUI` points at **Settings → Library** and a page reload;
both reuse the nav-path renderings of `other.noPaths.*` / `other.disabled.hint`.
- `disabled.*` mirrors `other.disabled.*` sentence by sentence ("management is off", "Enable it
to …", "Failed to enable …"), and `noPaths.*` mirrors `other.noPaths.*` — including the three
`open*` buttons, which are byte-identical to their `other.noPaths.open*` twins.
- Failure toasts carry `{message}` verbatim. CJK uses the full-width colon (`:`); ko and the
Latin/Cyrillic/Hebrew locales use ASCII (fr with its space, ` : `). The `restoring` loading
line follows each locale's existing "… in progress" shape (`recipesPathMigrating`).
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Embedding-Syntax kopiert",
"modelNameCopied": "Modellname kopiert",
"sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden"
"sendEmbeddingToWorkflow": "An ComfyUI senden",
"openInComfyUI": "In ComfyUI öffnen",
"copyWorkflowToClipboard": "Workflow in die Zwischenablage kopieren"
},
"nsfw": {
"matureContent": "Nicht jugendfreie Inhalte",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Andere",
"workflows": "Workflows",
"statistics": "Statistiken"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "Legen Sie den Standard-ControlNet-Stammordner für Downloads, Importe und Verschiebungen fest",
"enableOtherModels": "Verwaltung weiterer Modelle",
"enableOtherModelsHelp": "Wenn deaktiviert, werden VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Ordner nicht gescannt, die Seite für weitere Modelle bleibt deaktiviert und diese Modelltypen können nicht heruntergeladen werden.",
"enableWorkflows": "Workflow-Verwaltung",
"enableWorkflowsHelp": "Wenn deaktiviert, werden Workflow-Ordner (folder_paths.workflows) nicht gescannt, die Workflows-Seite bleibt deaktiviert und CivitAI-Workflow-Ressourcen können weder verknüpft noch heruntergeladen werden.",
"workflowFolders": "Workflow-Ordner",
"workflowFoldersHelp": "Ordner, die nach ComfyUI-Workflow-JSON-Dateien gescannt werden. Werden nur von LoRA Manager verwaltet (nicht mit ComfyUI geteilt); Änderungen gelten sofort.",
"otherSubTypes": "Verwaltete Modelltypen",
"otherSubTypesHelp": "Wählen Sie, welche Kategorien weiterer Modelle gescannt und auf der Seite für weitere Modelle angezeigt werden.",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Text-Encoder-Pfade",
"clip": "CLIP-Pfade (Legacy)",
"clip_vision": "CLIP-Vision-Pfade",
"controlnet": "ControlNet-Pfade"
"controlnet": "ControlNet-Pfade",
"workflows": "Workflow-Pfade"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "Einstellungsordner öffnen"
}
},
"workflows": {
"title": "Workflows",
"contextMenu": {
"sendToComfy": "In ComfyUI öffnen",
"updateFromComfy": "Von ComfyUI aktualisieren",
"copyWorkflow": "Workflow kopieren",
"restoreDownloaded": "Heruntergeladene Version wiederherstellen"
},
"sendToComfy": {
"success": "Workflow an ComfyUI gesendet",
"failed": "Fehler beim Senden des Workflows: {message}"
},
"restoreDownloaded": {
"title": "Heruntergeladene Version wiederherstellen?",
"message": "Dadurch werden lokale Änderungen verworfen und der Workflow auf die heruntergeladene Version zurückgesetzt.",
"confirmButton": "Wiederherstellen",
"restoring": "Workflow wird wiederhergestellt...",
"success": "Workflow auf die heruntergeladene Version zurückgesetzt",
"failed": "Fehler beim Wiederherstellen des Workflows: {message}"
},
"updateFromComfy": {
"success": "Workflow von ComfyUI aktualisiert",
"noChanges": "Keine Änderungen in ComfyUI zum Zurückschreiben",
"cancelled": "Aktualisierung abgebrochen",
"failed": "Fehler beim Aktualisieren von ComfyUI: {message}"
},
"copyWorkflow": {
"failed": "Fehler beim Kopieren des Workflows: {message}"
},
"disabled": {
"title": "Die Workflow-Verwaltung ist deaktiviert",
"description": "Aktivieren Sie die Option, um ComfyUI-Workflow-Dateien zu scannen und zu verwalten, ihre CivitAI-Metadaten und Vorschaubilder zu behalten und sie in den laufenden ComfyUI-Graph zu senden.",
"enableButton": "Workflows aktivieren",
"hint": "Sie können die Funktion später unter Einstellungen > Bibliothek wieder deaktivieren.",
"enableFailed": "Aktivierung der Workflows fehlgeschlagen"
},
"noPaths": {
"title": "Keine Workflow-Ordner gefunden",
"descriptionStandalone": "Die Workflow-Verwaltung ist aktiviert, aber es wurden keine Workflow-Ordner gefunden. Fügen Sie Ihre Workflow-Ordner unter Einstellungen → Modellpfade hinzu und starten Sie LoRA Manager anschließend neu.",
"hintStandalone": "Workflows werden aus den folder_paths.workflows-Einträgen gelesen.",
"descriptionComfyUI": "Die Workflow-Verwaltung ist aktiviert, aber es sind keine Workflow-Ordner konfiguriert. Fügen Sie Workflow-Ordner unter Einstellungen → Bibliothek hinzu und laden Sie diese Seite neu.",
"hintComfyUI": "Workflows liegen in eigenen Bibliotheksordnern (folder_paths.workflows); die integrierte Workflow-Liste von ComfyUI wird nicht verändert.",
"openSettings": "Einstellungen öffnen",
"openModelPaths": "Modellordner konfigurieren",
"openSettingsFolder": "Einstellungsordner öffnen"
}
},
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
@@ -2272,6 +2327,10 @@
"title": "Manager für weitere Modelle wird initialisiert",
"message": "Modell-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
},
"workflows": {
"title": "Workflow Manager wird initialisiert",
"message": "Workflow-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
},
"recipes": {
"title": "Rezept Manager wird initialisiert",
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Embedding syntax copied",
"modelNameCopied": "Model name copied",
"sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI"
"sendEmbeddingToWorkflow": "Send to ComfyUI",
"openInComfyUI": "Open in ComfyUI",
"copyWorkflowToClipboard": "Copy workflow to clipboard"
},
"nsfw": {
"matureContent": "Mature Content",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Other",
"workflows": "Workflows",
"statistics": "Stats"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "Other Models Management",
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP vision / ControlNet folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"enableWorkflows": "Workflow Management",
"enableWorkflowsHelp": "When off, workflow folders (folder_paths.workflows) are not scanned, the Workflows page stays disabled, and CivitAI Workflow resources cannot be linked or downloaded.",
"workflowFolders": "Workflow Folders",
"workflowFoldersHelp": "Folders scanned for ComfyUI workflow JSON files. Managed by LoRA Manager only (not shared with ComfyUI); changes apply immediately.",
"otherSubTypes": "Managed Types",
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Text Encoder Paths",
"clip": "CLIP Paths (legacy)",
"clip_vision": "CLIP Vision Paths",
"controlnet": "ControlNet Paths"
"controlnet": "ControlNet Paths",
"workflows": "Workflow Paths"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "Open Settings Folder"
}
},
"workflows": {
"title": "Workflows",
"contextMenu": {
"sendToComfy": "Open in ComfyUI",
"updateFromComfy": "Update from ComfyUI",
"copyWorkflow": "Copy Workflow",
"restoreDownloaded": "Restore Downloaded Version"
},
"sendToComfy": {
"success": "Workflow sent to ComfyUI",
"failed": "Failed to send workflow: {message}"
},
"restoreDownloaded": {
"title": "Restore Downloaded Version?",
"message": "This discards local edits and restores the workflow to its downloaded version.",
"confirmButton": "Restore",
"restoring": "Restoring workflow...",
"success": "Workflow restored to its downloaded version",
"failed": "Failed to restore workflow: {message}"
},
"updateFromComfy": {
"success": "Workflow updated from ComfyUI",
"noChanges": "No changes in ComfyUI to write back",
"cancelled": "Update cancelled",
"failed": "Failed to update from ComfyUI: {message}"
},
"copyWorkflow": {
"failed": "Failed to copy workflow: {message}"
},
"disabled": {
"title": "Workflow management is off",
"description": "Enable it to scan and manage ComfyUI workflow files, keep their CivitAI metadata and previews, and send them into the running ComfyUI graph.",
"enableButton": "Enable Workflows",
"hint": "You can turn the feature off again later in Settings > Library.",
"enableFailed": "Failed to enable Workflows"
},
"noPaths": {
"title": "No workflow folders found",
"descriptionStandalone": "Workflow management is on, but no workflow folders were found. Add your workflow folders under Settings → Model Paths, then restart LoRA Manager.",
"hintStandalone": "Workflows are read from the folder_paths.workflows entries.",
"descriptionComfyUI": "Workflow management is on, but no workflow folders are configured. Add workflow folders in Settings → Library, then reload this page.",
"hintComfyUI": "Workflows live in their own library folders (folder_paths.workflows); ComfyUI's built-in workflow list is not touched.",
"openSettings": "Open Settings",
"openModelPaths": "Configure Model Folders",
"openSettingsFolder": "Open Settings Folder"
}
},
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
@@ -2272,6 +2327,10 @@
"title": "Initializing Other Models Manager",
"message": "Scanning and building model cache. This may take a few minutes..."
},
"workflows": {
"title": "Initializing Workflow Manager",
"message": "Scanning and building workflow cache. This may take a few minutes..."
},
"recipes": {
"title": "Initializing Recipe Manager",
"message": "Loading and processing recipes. This may take a few minutes..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Sintaxis de embedding copiada",
"modelNameCopied": "Nombre del modelo copiado",
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
"sendEmbeddingToWorkflow": "Enviar a ComfyUI",
"openInComfyUI": "Abrir en ComfyUI",
"copyWorkflowToClipboard": "Copiar el workflow al portapapeles"
},
"nsfw": {
"matureContent": "Contenido para adultos",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Otros",
"workflows": "Workflows",
"statistics": "Estadísticas"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "Establecer el directorio raíz predeterminado de ControlNet para descargas, importaciones y movimientos",
"enableOtherModels": "Gestión de otros modelos",
"enableOtherModelsHelp": "Cuando está desactivado, las carpetas VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet no se escanean, la página Otros modelos permanece desactivada y estos tipos de modelos no se pueden descargar.",
"enableWorkflows": "Gestión de workflows",
"enableWorkflowsHelp": "Cuando está desactivado, las carpetas de workflows (folder_paths.workflows) no se escanean, la página Workflows permanece desactivada y los recursos Workflow de CivitAI no se pueden vincular ni descargar.",
"workflowFolders": "Carpetas de workflows",
"workflowFoldersHelp": "Carpetas que se escanean en busca de archivos JSON de workflow de ComfyUI. Gestionadas solo por LoRA Manager (no se comparten con ComfyUI); los cambios se aplican de inmediato.",
"otherSubTypes": "Tipos de modelos gestionados",
"otherSubTypesHelp": "Elige qué categorías de otros modelos se escanean y se muestran en la página Otros modelos.",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Rutas de Text Encoder",
"clip": "Rutas de CLIP (heredadas)",
"clip_vision": "Rutas de CLIP Vision",
"controlnet": "Rutas de ControlNet"
"controlnet": "Rutas de ControlNet",
"workflows": "Rutas de workflows"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "Abrir carpeta de ajustes"
}
},
"workflows": {
"title": "Workflows",
"contextMenu": {
"sendToComfy": "Abrir en ComfyUI",
"updateFromComfy": "Actualizar desde ComfyUI",
"copyWorkflow": "Copiar workflow",
"restoreDownloaded": "Restaurar versión descargada"
},
"sendToComfy": {
"success": "Workflow enviado a ComfyUI",
"failed": "Error al enviar el workflow: {message}"
},
"restoreDownloaded": {
"title": "¿Restaurar versión descargada?",
"message": "Esto descarta los cambios locales y restaura el workflow a su versión descargada.",
"confirmButton": "Restaurar",
"restoring": "Restaurando el workflow...",
"success": "Workflow restaurado a su versión descargada",
"failed": "Error al restaurar el workflow: {message}"
},
"updateFromComfy": {
"success": "Workflow actualizado desde ComfyUI",
"noChanges": "No hay cambios en ComfyUI que escribir de vuelta",
"cancelled": "Actualización cancelada",
"failed": "Error al actualizar desde ComfyUI: {message}"
},
"copyWorkflow": {
"failed": "Error al copiar el workflow: {message}"
},
"disabled": {
"title": "La gestión de workflows está desactivada",
"description": "Actívala para escanear y gestionar archivos de workflow de ComfyUI, conservar sus metadatos y vistas previas de CivitAI y enviarlos al grafo de ComfyUI en ejecución.",
"enableButton": "Activar workflows",
"hint": "Puedes desactivar esta función más adelante en Configuración > Biblioteca.",
"enableFailed": "No se pudieron activar los workflows"
},
"noPaths": {
"title": "No se encontraron carpetas de workflows",
"descriptionStandalone": "La gestión de workflows está activada, pero no se encontraron carpetas de workflows. Añade tus carpetas de workflows en Configuración → Rutas de modelos y reinicia LoRA Manager.",
"hintStandalone": "Los workflows se leen de las entradas folder_paths.workflows.",
"descriptionComfyUI": "La gestión de workflows está activada, pero no hay carpetas de workflows configuradas. Añade carpetas de workflows en Configuración → Biblioteca y recarga esta página.",
"hintComfyUI": "Los workflows se guardan en sus propias carpetas de biblioteca (folder_paths.workflows); la lista de workflows integrada de ComfyUI no se modifica.",
"openSettings": "Abrir configuración",
"openModelPaths": "Configurar carpetas de modelos",
"openSettingsFolder": "Abrir carpeta de ajustes"
}
},
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
@@ -2272,6 +2327,10 @@
"title": "Inicializando el gestor de otros modelos",
"message": "Escaneando y construyendo la caché de modelos. Esto puede tomar unos minutos..."
},
"workflows": {
"title": "Inicializando gestor de workflows",
"message": "Escaneando y construyendo caché de workflows. Esto puede tomar unos minutos..."
},
"recipes": {
"title": "Inicializando gestor de recetas",
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Syntaxe dembedding copiée",
"modelNameCopied": "Nom du modèle copié",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI",
"openInComfyUI": "Ouvrir dans ComfyUI",
"copyWorkflowToClipboard": "Copier le workflow dans le presse-papiers"
},
"nsfw": {
"matureContent": "Contenu pour adultes",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Autres",
"workflows": "Workflows",
"statistics": "Statistiques"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "Définir le répertoire racine ControlNet par défaut pour les téléchargements, imports et déplacements",
"enableOtherModels": "Gestion des autres modèles",
"enableOtherModelsHelp": "Lorsque cette option est désactivée, les dossiers VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet ne sont pas analysés, la page Autres modèles reste désactivée et ces types de modèles ne peuvent pas être téléchargés.",
"enableWorkflows": "Gestion des workflows",
"enableWorkflowsHelp": "Lorsque cette option est désactivée, les dossiers de workflows (folder_paths.workflows) ne sont pas analysés, la page Workflows reste désactivée et les ressources Workflow de CivitAI ne peuvent pas être liées ni téléchargées.",
"workflowFolders": "Dossiers de workflows",
"workflowFoldersHelp": "Dossiers analysés pour les fichiers JSON de workflow ComfyUI. Gérés uniquement par LoRA Manager (non partagés avec ComfyUI) ; les modifications s'appliquent immédiatement.",
"otherSubTypes": "Types de modèles gérés",
"otherSubTypesHelp": "Choisissez les catégories d’autres modèles analysées et affichées sur la page Autres modèles.",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Chemins Text Encoder",
"clip": "Chemins CLIP (hérité)",
"clip_vision": "Chemins CLIP Vision",
"controlnet": "Chemins ControlNet"
"controlnet": "Chemins ControlNet",
"workflows": "Chemins de workflows"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "Ouvrir le dossier des paramètres"
}
},
"workflows": {
"title": "Workflows",
"contextMenu": {
"sendToComfy": "Ouvrir dans ComfyUI",
"updateFromComfy": "Mettre à jour depuis ComfyUI",
"copyWorkflow": "Copier le workflow",
"restoreDownloaded": "Restaurer la version téléchargée"
},
"sendToComfy": {
"success": "Workflow envoyé vers ComfyUI",
"failed": "Échec de l'envoi du workflow : {message}"
},
"restoreDownloaded": {
"title": "Restaurer la version téléchargée ?",
"message": "Cela supprime les modifications locales et restaure le workflow dans sa version téléchargée.",
"confirmButton": "Restaurer",
"restoring": "Restauration du workflow en cours...",
"success": "Workflow restauré dans sa version téléchargée",
"failed": "Échec de la restauration du workflow : {message}"
},
"updateFromComfy": {
"success": "Workflow mis à jour depuis ComfyUI",
"noChanges": "Aucune modification à réécrire depuis ComfyUI",
"cancelled": "Mise à jour annulée",
"failed": "Échec de la mise à jour depuis ComfyUI : {message}"
},
"copyWorkflow": {
"failed": "Échec de la copie du workflow : {message}"
},
"disabled": {
"title": "La gestion des workflows est désactivée",
"description": "Activez-la pour analyser et gérer les fichiers de workflow ComfyUI, conserver leurs métadonnées et aperçus CivitAI et les envoyer dans le graphe ComfyUI en cours d'exécution.",
"enableButton": "Activer les workflows",
"hint": "Vous pourrez désactiver cette fonctionnalité plus tard dans Paramètres > Bibliothèque.",
"enableFailed": "Échec de l'activation des workflows"
},
"noPaths": {
"title": "Aucun dossier de workflows trouvé",
"descriptionStandalone": "La gestion des workflows est activée, mais aucun dossier de workflows n'a été trouvé. Ajoutez vos dossiers de workflows dans Paramètres → Chemins de modèles, puis redémarrez LoRA Manager.",
"hintStandalone": "Les workflows sont lus depuis les entrées folder_paths.workflows.",
"descriptionComfyUI": "La gestion des workflows est activée, mais aucun dossier de workflows n'est configuré. Ajoutez des dossiers de workflows dans Paramètres → Bibliothèque, puis rechargez cette page.",
"hintComfyUI": "Les workflows résident dans leurs propres dossiers de bibliothèque (folder_paths.workflows) ; la liste de workflows intégrée à ComfyUI n'est pas modifiée.",
"openSettings": "Ouvrir les paramètres",
"openModelPaths": "Configurer les dossiers de modèles",
"openSettingsFolder": "Ouvrir le dossier des paramètres"
}
},
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
@@ -2272,6 +2327,10 @@
"title": "Initialisation du gestionnaire Autres modèles",
"message": "Analyse et construction du cache de modèles. Cela peut prendre quelques minutes..."
},
"workflows": {
"title": "Initialisation du gestionnaire de workflows",
"message": "Scan et construction du cache workflow. Cela peut prendre quelques minutes..."
},
"recipes": {
"title": "Initialisation du gestionnaire de recipes",
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "תחביר Embedding הועתק",
"modelNameCopied": "שם המודל הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI",
"openInComfyUI": "פתח ב-ComfyUI",
"copyWorkflowToClipboard": "העתק workflow ללוח"
},
"nsfw": {
"matureContent": "תוכן למבוגרים",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "אחרים",
"workflows": "Workflows",
"statistics": "סטטיסטיקה"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של ControlNet להורדות, ייבוא והעברות",
"enableOtherModels": "ניהול מודלים אחרים",
"enableOtherModelsHelp": "כשהאפשרות כבויה, תיקיות VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet אינן נסרקות, עמוד המודלים האחרים נשאר מושבת ולא ניתן להוריד סוגי מודלים אלה.",
"enableWorkflows": "ניהול workflow",
"enableWorkflowsHelp": "כשהאפשרות כבויה, תיקיות workflow (folder_paths.workflows) אינן נסרקות, עמוד Workflows נשאר מושבת ולא ניתן לקשר או להוריד משאבי Workflow מ-CivitAI.",
"workflowFolders": "תיקיות workflow",
"workflowFoldersHelp": "תיקיות שנסרקות עבור קובצי JSON של workflow של ComfyUI. מנוהלות על ידי LoRA Manager בלבד (אינן משותפות עם ComfyUI); השינויים חלים מיד.",
"otherSubTypes": "סוגי מודלים מנוהלים",
"otherSubTypesHelp": "בחר אילו קטגוריות של מודלים אחרים ייסרקו ויוצגו בעמוד המודלים האחרים.",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "נתיבי Text Encoder",
"clip": "נתיבי CLIP (ישן)",
"clip_vision": "נתיבי CLIP Vision",
"controlnet": "נתיבי ControlNet"
"controlnet": "נתיבי ControlNet",
"workflows": "נתיבי workflow"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "פתח תיקיית הגדרות"
}
},
"workflows": {
"title": "Workflows",
"contextMenu": {
"sendToComfy": "פתח ב-ComfyUI",
"updateFromComfy": "עדכן מ-ComfyUI",
"copyWorkflow": "העתק workflow",
"restoreDownloaded": "שחזר את הגרסה שהורדה"
},
"sendToComfy": {
"success": "ה-workflow נשלח ל-ComfyUI",
"failed": "שליחת ה-workflow נכשלה: {message}"
},
"restoreDownloaded": {
"title": "לשחזר את הגרסה שהורדה?",
"message": "פעולה זו מבטלת עריכות מקומיות ומשחזרת את ה-workflow לגרסה שהורדה.",
"confirmButton": "שחזר",
"restoring": "משחזר workflow...",
"success": "ה-workflow שוחזר לגרסה שהורדה",
"failed": "שחזור ה-workflow נכשל: {message}"
},
"updateFromComfy": {
"success": "ה-workflow עודכן מ-ComfyUI",
"noChanges": "אין שינויים ב-ComfyUI לכתיבה חזרה",
"cancelled": "העדכון בוטל",
"failed": "העדכון מ-ComfyUI נכשל: {message}"
},
"copyWorkflow": {
"failed": "העתקת ה-workflow נכשלה: {message}"
},
"disabled": {
"title": "ניהול ה-workflow כבוי",
"description": "הפעל כדי לסרוק ולנהל קבצי workflow של ComfyUI, לשמור את המטא-נתונים והתצוגות המקדימות שלהם מ-CivitAI, ולשלוח אותם ל-ComfyUI הפעיל.",
"enableButton": "הפעל Workflows",
"hint": "ניתן לכבות את התכונה שוב מאוחר יותר בהגדרות > ספרייה.",
"enableFailed": "הפעלת Workflows נכשלה"
},
"noPaths": {
"title": "לא נמצאו תיקיות workflow",
"descriptionStandalone": "ניהול ה-workflow פועל, אך לא נמצאו תיקיות workflow. הוסף את תיקיות ה-workflow שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
"hintStandalone": "קבצי workflow נקראים מהערכים של folder_paths.workflows.",
"descriptionComfyUI": "ניהול ה-workflow פועל, אך לא הוגדרו תיקיות workflow. הוסף תיקיות workflow תחת הגדרות > ספרייה, ולאחר מכן טען מחדש עמוד זה.",
"hintComfyUI": "קבצי workflow נמצאים בתיקיות ספרייה נפרדות (folder_paths.workflows); רשימת ה-workflow המובנית של ComfyUI אינה מושפעת.",
"openSettings": "פתח הגדרות",
"openModelPaths": "הגדר תיקיות מודלים",
"openSettingsFolder": "פתח תיקיית הגדרות"
}
},
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
@@ -2272,6 +2327,10 @@
"title": "מאתחל את מנהל המודלים האחרים",
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
},
"workflows": {
"title": "מאתחל מנהל Workflow",
"message": "סורק ובונה מטמון workflow. זה עשוי לקחת מספר דקות..."
},
"recipes": {
"title": "מאתחל מנהל מתכונים",
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Embedding構文をコピーしました",
"modelNameCopied": "モデル名をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
"sendEmbeddingToWorkflow": "ComfyUIに送信",
"openInComfyUI": "ComfyUIで開く",
"copyWorkflowToClipboard": "ワークフローをクリップボードにコピー"
},
"nsfw": {
"matureContent": "成人向けコンテンツ",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "その他",
"workflows": "ワークフロー",
"statistics": "統計"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "ダウンロード、インポート、移動用のデフォルトControlNetルートディレクトリを設定",
"enableOtherModels": "その他のモデル管理",
"enableOtherModelsHelp": "オフにすると、VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet フォルダーはスキャンされず、その他のモデルページは無効のままになり、これらのモデルタイプはダウンロードできません。",
"enableWorkflows": "ワークフロー管理",
"enableWorkflowsHelp": "オフにすると、ワークフローフォルダー(folder_paths.workflows)はスキャンされず、ワークフローページは無効のままになり、CivitAIのWorkflowリソースをリンクしたりダウンロードしたりできなくなります。",
"workflowFolders": "ワークフローフォルダー",
"workflowFoldersHelp": "ComfyUIのワークフローJSONファイルをスキャンするフォルダーです。LoRA Managerのみが管理し(ComfyUIとは共有されません)、変更はすぐに反映されます。",
"otherSubTypes": "管理するモデルタイプ",
"otherSubTypesHelp": "その他のモデルページでスキャンおよび表示するカテゴリを選択します。",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Text Encoderパス",
"clip": "CLIPパス(レガシー)",
"clip_vision": "CLIP Visionパス",
"controlnet": "ControlNetパス"
"controlnet": "ControlNetパス",
"workflows": "ワークフローパス"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "設定フォルダーを開く"
}
},
"workflows": {
"title": "ワークフロー",
"contextMenu": {
"sendToComfy": "ComfyUIで開く",
"updateFromComfy": "ComfyUIから更新",
"copyWorkflow": "ワークフローをコピー",
"restoreDownloaded": "ダウンロード済みバージョンを復元"
},
"sendToComfy": {
"success": "ワークフローをComfyUIへ送信しました",
"failed": "ワークフローの送信に失敗しました:{message}"
},
"restoreDownloaded": {
"title": "ダウンロード済みバージョンを復元しますか?",
"message": "ローカルの編集内容は破棄され、ワークフローはダウンロード時のバージョンに復元されます。",
"confirmButton": "復元",
"restoring": "ワークフローを復元中...",
"success": "ワークフローをダウンロード済みバージョンに復元しました",
"failed": "ワークフローの復元に失敗しました:{message}"
},
"updateFromComfy": {
"success": "ComfyUIからワークフローを更新しました",
"noChanges": "ComfyUI側に書き戻す変更はありません",
"cancelled": "更新はキャンセルされました",
"failed": "ComfyUIからの更新に失敗しました:{message}"
},
"copyWorkflow": {
"failed": "ワークフローのコピーに失敗しました:{message}"
},
"disabled": {
"title": "ワークフロー管理はオフです",
"description": "有効にすると、ComfyUIのワークフローファイルをスキャン・管理し、CivitAIのメタデータとプレビューを保持したまま、実行中のComfyUIに送信できます。",
"enableButton": "ワークフローを有効にする",
"hint": "この機能は後で「設定 > ライブラリ」から再度オフにできます。",
"enableFailed": "ワークフローの有効化に失敗しました"
},
"noPaths": {
"title": "ワークフローフォルダーが見つかりません",
"descriptionStandalone": "ワークフロー管理はオンですが、ワークフローフォルダーが見つかりませんでした。「設定 > モデルパス」でワークフローフォルダーを追加し、LoRA Managerを再起動してください。",
"hintStandalone": "ワークフローはfolder_paths.workflowsのエントリから読み込まれます。",
"descriptionComfyUI": "ワークフロー管理はオンですが、ワークフローフォルダーが設定されていません。「設定 > ライブラリ」でワークフローフォルダーを追加し、このページを再読み込みしてください。",
"hintComfyUI": "ワークフローは専用のライブラリフォルダー(folder_paths.workflows)に保存されます。ComfyUI標準のワークフローリストには影響しません。",
"openSettings": "設定を開く",
"openModelPaths": "モデルフォルダーを設定",
"openSettingsFolder": "設定フォルダーを開く"
}
},
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
@@ -2272,6 +2327,10 @@
"title": "その他のモデルマネージャーを初期化中",
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
},
"workflows": {
"title": "ワークフローマネージャーを初期化中",
"message": "ワークフローキャッシュをスキャンして構築中。数分かかる場合があります..."
},
"recipes": {
"title": "レシピマネージャーを初期化中",
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Embedding 구문 복사됨",
"modelNameCopied": "모델 이름 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
"sendEmbeddingToWorkflow": "ComfyUI로 전송",
"openInComfyUI": "ComfyUI에서 열기",
"copyWorkflowToClipboard": "워크플로를 클립보드에 복사"
},
"nsfw": {
"matureContent": "성인 콘텐츠",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "기타",
"workflows": "워크플로",
"statistics": "통계"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 ControlNet 루트 디렉토리를 설정합니다",
"enableOtherModels": "기타 모델 관리",
"enableOtherModelsHelp": "끄면 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 폴더를 스캔하지 않으며, 기타 모델 페이지가 비활성화된 상태로 유지되고 이러한 모델 유형은 다운로드할 수 없습니다.",
"enableWorkflows": "워크플로 관리",
"enableWorkflowsHelp": "끄면 워크플로 폴더(folder_paths.workflows)를 스캔하지 않으며, 워크플로 페이지가 비활성화된 상태로 유지되고 CivitAI의 Workflow 리소스를 연결하거나 다운로드할 수 없습니다.",
"workflowFolders": "워크플로 폴더",
"workflowFoldersHelp": "ComfyUI 워크플로 JSON 파일을 스캔할 폴더입니다. LoRA Manager에서만 관리하며(ComfyUI와 공유되지 않음), 변경 사항은 즉시 적용됩니다.",
"otherSubTypes": "관리할 모델 유형",
"otherSubTypesHelp": "기타 모델 페이지에서 스캔하고 표시할 카테고리를 선택합니다.",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Text Encoder 경로",
"clip": "CLIP 경로 (레거시)",
"clip_vision": "CLIP Vision 경로",
"controlnet": "ControlNet 경로"
"controlnet": "ControlNet 경로",
"workflows": "워크플로 경로"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "설정 폴더 열기"
}
},
"workflows": {
"title": "워크플로",
"contextMenu": {
"sendToComfy": "ComfyUI에서 열기",
"updateFromComfy": "ComfyUI에서 업데이트",
"copyWorkflow": "워크플로 복사",
"restoreDownloaded": "다운로드한 버전 복원"
},
"sendToComfy": {
"success": "워크플로를 ComfyUI로 보냈습니다",
"failed": "워크플로 전송 실패: {message}"
},
"restoreDownloaded": {
"title": "다운로드한 버전을 복원할까요?",
"message": "로컬 편집 내용이 삭제되고 워크플로가 다운로드한 버전으로 복원됩니다.",
"confirmButton": "복원",
"restoring": "워크플로 복원 중...",
"success": "워크플로를 다운로드한 버전으로 복원했습니다",
"failed": "워크플로 복원 실패: {message}"
},
"updateFromComfy": {
"success": "ComfyUI에서 워크플로를 업데이트했습니다",
"noChanges": "ComfyUI에 다시 기록할 변경 사항이 없습니다",
"cancelled": "업데이트가 취소되었습니다",
"failed": "ComfyUI에서 업데이트 실패: {message}"
},
"copyWorkflow": {
"failed": "워크플로 복사 실패: {message}"
},
"disabled": {
"title": "워크플로 관리가 꺼져 있습니다",
"description": "활성화하면 ComfyUI 워크플로 파일을 스캔하고 관리하며, CivitAI 메타데이터와 미리보기를 유지하고 실행 중인 ComfyUI로 전송할 수 있습니다.",
"enableButton": "워크플로 활성화",
"hint": "나중에 설정 > 라이브러리에서 이 기능을 다시 끌 수 있습니다.",
"enableFailed": "워크플로 활성화 실패"
},
"noPaths": {
"title": "워크플로 폴더를 찾을 수 없습니다",
"descriptionStandalone": "워크플로 관리가 켜져 있지만, 워크플로 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 워크플로 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "워크플로는 folder_paths.workflows 항목에서 읽어옵니다.",
"descriptionComfyUI": "워크플로 관리가 켜져 있지만, 설정된 워크플로 폴더가 없습니다. 설정 → 라이브러리에서 워크플로 폴더를 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "워크플로는 자체 라이브러리 폴더(folder_paths.workflows)에 있으며, ComfyUI에 내장된 워크플로 목록은 변경되지 않습니다.",
"openSettings": "설정 열기",
"openModelPaths": "모델 폴더 구성",
"openSettingsFolder": "설정 폴더 열기"
}
},
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
@@ -2272,6 +2327,10 @@
"title": "기타 모델 관리자 초기화 중",
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"workflows": {
"title": "워크플로 매니저 초기화 중",
"message": "워크플로 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"recipes": {
"title": "레시피 매니저 초기화 중",
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "Синтаксис embedding скопирован",
"modelNameCopied": "Имя модели скопировано",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
"sendEmbeddingToWorkflow": "Отправить в ComfyUI",
"openInComfyUI": "Открыть в ComfyUI",
"copyWorkflowToClipboard": "Копировать workflow в буфер обмена"
},
"nsfw": {
"matureContent": "Контент для взрослых",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Другое",
"workflows": "Workflows",
"statistics": "Статистика"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "Установить корневую папку ControlNet по умолчанию для загрузок, импорта и перемещений",
"enableOtherModels": "Управление другими моделями",
"enableOtherModelsHelp": "Если выключено, папки VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet не сканируются, страница «Другие модели» остаётся отключённой, а эти типы моделей нельзя загрузить.",
"enableWorkflows": "Управление workflow",
"enableWorkflowsHelp": "Если выключено, папки workflow (folder_paths.workflows) не сканируются, страница Workflows остаётся отключённой, а ресурсы Workflow с CivitAI нельзя связать или скачать.",
"workflowFolders": "Папки workflow",
"workflowFoldersHelp": "Папки, в которых сканируются JSON-файлы workflow ComfyUI. Управляются только LoRA Manager (не используются совместно с ComfyUI); изменения применяются сразу.",
"otherSubTypes": "Управляемые типы моделей",
"otherSubTypesHelp": "Выберите, какие категории других моделей сканируются и отображаются на странице «Другие модели».",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Пути Text Encoder",
"clip": "Пути CLIP (устаревшие)",
"clip_vision": "Пути CLIP Vision",
"controlnet": "Пути ControlNet"
"controlnet": "Пути ControlNet",
"workflows": "Пути workflow"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "Открыть папку настроек"
}
},
"workflows": {
"title": "Workflows",
"contextMenu": {
"sendToComfy": "Открыть в ComfyUI",
"updateFromComfy": "Обновить из ComfyUI",
"copyWorkflow": "Копировать workflow",
"restoreDownloaded": "Восстановить загруженную версию"
},
"sendToComfy": {
"success": "Workflow отправлен в ComfyUI",
"failed": "Не удалось отправить workflow: {message}"
},
"restoreDownloaded": {
"title": "Восстановить загруженную версию?",
"message": "Локальные изменения будут отброшены, а workflow вернётся к загруженной версии.",
"confirmButton": "Восстановить",
"restoring": "Восстановление workflow...",
"success": "Workflow восстановлен до загруженной версии",
"failed": "Не удалось восстановить workflow: {message}"
},
"updateFromComfy": {
"success": "Workflow обновлён из ComfyUI",
"noChanges": "В ComfyUI нет изменений для записи обратно",
"cancelled": "Обновление отменено",
"failed": "Не удалось обновить из ComfyUI: {message}"
},
"copyWorkflow": {
"failed": "Не удалось скопировать workflow: {message}"
},
"disabled": {
"title": "Управление workflow отключено",
"description": "Включите, чтобы сканировать и управлять файлами workflow ComfyUI, сохранять их метаданные и превью CivitAI и отправлять их в работающий граф ComfyUI.",
"enableButton": "Включить Workflows",
"hint": "Вы сможете снова отключить эту функцию позже в разделе «Настройки > Библиотека».",
"enableFailed": "Не удалось включить Workflows"
},
"noPaths": {
"title": "Папки workflow не найдены",
"descriptionStandalone": "Управление workflow включено, но папки workflow не найдены. Добавьте свои папки workflow в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
"hintStandalone": "Workflow читаются из записей folder_paths.workflows.",
"descriptionComfyUI": "Управление workflow включено, но папки workflow не настроены. Добавьте папки workflow в разделе «Настройки → Библиотека» и перезагрузите эту страницу.",
"hintComfyUI": "Workflow хранятся в собственных папках библиотеки (folder_paths.workflows); встроенный список workflow в ComfyUI не затрагивается.",
"openSettings": "Открыть настройки",
"openModelPaths": "Настроить папки моделей",
"openSettingsFolder": "Открыть папку настроек"
}
},
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
@@ -2272,6 +2327,10 @@
"title": "Инициализация менеджера других моделей",
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
},
"workflows": {
"title": "Инициализация менеджера workflow",
"message": "Сканирование и построение кэша workflow. Это может занять несколько минут..."
},
"recipes": {
"title": "Инициализация менеджера рецептов",
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "已复制 Embedding 语法",
"modelNameCopied": "模型名称已复制",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
"sendEmbeddingToWorkflow": "发送到 ComfyUI",
"openInComfyUI": "在 ComfyUI 中打开",
"copyWorkflowToClipboard": "复制工作流到剪贴板"
},
"nsfw": {
"matureContent": "成熟内容",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"workflows": "工作流",
"statistics": "统计"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "设置下载、导入和移动时的默认 ControlNet 根目录",
"enableOtherModels": "其他模型管理",
"enableOtherModelsHelp": "关闭后,不会扫描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 文件夹,其他模型页面保持禁用,且无法下载这些模型类型。",
"enableWorkflows": "工作流管理",
"enableWorkflowsHelp": "关闭后,不会扫描工作流文件夹(folder_paths.workflows),工作流页面保持禁用,且无法关联或下载 CivitAI 的 Workflow 资源。",
"workflowFolders": "工作流文件夹",
"workflowFoldersHelp": "扫描 ComfyUI 工作流 JSON 文件的文件夹。仅由 LoRA Manager 管理(不与 ComfyUI 共享);更改立即生效。",
"otherSubTypes": "管理的模型类型",
"otherSubTypesHelp": "选择要在其他模型页面中扫描和显示的其他模型类别。",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Text Encoder 路径",
"clip": "CLIP 路径(旧版)",
"clip_vision": "CLIP Vision 路径",
"controlnet": "ControlNet 路径"
"controlnet": "ControlNet 路径",
"workflows": "工作流路径"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "打开设置文件夹"
}
},
"workflows": {
"title": "工作流",
"contextMenu": {
"sendToComfy": "在 ComfyUI 中打开",
"updateFromComfy": "从 ComfyUI 更新",
"copyWorkflow": "复制工作流",
"restoreDownloaded": "恢复已下载版本"
},
"sendToComfy": {
"success": "工作流已发送到 ComfyUI",
"failed": "发送工作流失败:{message}"
},
"restoreDownloaded": {
"title": "恢复已下载版本?",
"message": "这会丢弃本地修改,并将工作流恢复到下载时的版本。",
"confirmButton": "恢复",
"restoring": "正在恢复工作流...",
"success": "工作流已恢复到已下载版本",
"failed": "恢复工作流失败:{message}"
},
"updateFromComfy": {
"success": "已从 ComfyUI 更新工作流",
"noChanges": "ComfyUI 中没有需要写回的更改",
"cancelled": "更新已取消",
"failed": "从 ComfyUI 更新失败:{message}"
},
"copyWorkflow": {
"failed": "复制工作流失败:{message}"
},
"disabled": {
"title": "工作流管理已关闭",
"description": "启用后可扫描和管理 ComfyUI 工作流文件,保留其 CivitAI 元数据和预览,并将它们发送到正在运行的 ComfyUI 中。",
"enableButton": "启用工作流",
"hint": "你可以稍后在“设置 > 库”中再次关闭此功能。",
"enableFailed": "启用工作流失败"
},
"noPaths": {
"title": "未找到工作流文件夹",
"descriptionStandalone": "工作流管理已开启,但未找到工作流文件夹。请在“设置 → 模型路径”中添加工作流文件夹,然后重启 LoRA Manager。",
"hintStandalone": "工作流从 folder_paths.workflows 条目中读取。",
"descriptionComfyUI": "工作流管理已开启,但未配置工作流文件夹。请在“设置 → 库”中添加工作流文件夹,然后重新加载此页面。",
"hintComfyUI": "工作流存放在独立的库文件夹中(folder_paths.workflows);不会影响 ComfyUI 内置的工作流列表。",
"openSettings": "打开设置",
"openModelPaths": "配置模型文件夹",
"openSettingsFolder": "打开设置文件夹"
}
},
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
@@ -2272,6 +2327,10 @@
"title": "正在初始化其他模型管理器",
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
},
"workflows": {
"title": "初始化工作流管理器",
"message": "正在扫描并构建工作流缓存。这可能需要几分钟..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在加载和处理配方。这可能需要几分钟..."
+61 -2
View File
@@ -156,7 +156,9 @@
"embeddingNameCopied": "已複製 Embedding 語法",
"modelNameCopied": "模型名稱已複製",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
"sendEmbeddingToWorkflow": "傳送到 ComfyUI",
"openInComfyUI": "在 ComfyUI 中開啟",
"copyWorkflowToClipboard": "複製工作流到剪貼簿"
},
"nsfw": {
"matureContent": "成熟內容",
@@ -244,6 +246,7 @@
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"workflows": "工作流",
"statistics": "統計"
},
"search": {
@@ -633,6 +636,10 @@
"defaultControlnetRootHelp": "設定下載、匯入和移動時的預設 ControlNet 根目錄",
"enableOtherModels": "其他模型管理",
"enableOtherModelsHelp": "關閉後,不會掃描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 資料夾,其他模型頁面會保持停用,且無法下載這些模型類型。",
"enableWorkflows": "工作流管理",
"enableWorkflowsHelp": "關閉後,不會掃描工作流資料夾(folder_paths.workflows),工作流頁面會保持停用,且無法連結或下載 CivitAI 的 Workflow 資源。",
"workflowFolders": "工作流資料夾",
"workflowFoldersHelp": "掃描 ComfyUI 工作流 JSON 檔案的資料夾。僅由 LoRA Manager 管理(不與 ComfyUI 共用);變更立即生效。",
"otherSubTypes": "管理的模型類型",
"otherSubTypesHelp": "選擇要在其他模型頁面中掃描和顯示的其他模型類別。",
"subTypeVae": "VAE",
@@ -686,7 +693,8 @@
"text_encoders": "Text Encoder 路徑",
"clip": "CLIP 路徑(舊版)",
"clip_vision": "CLIP Vision 路徑",
"controlnet": "ControlNet 路徑"
"controlnet": "ControlNet 路徑",
"workflows": "工作流路徑"
}
},
"directoryPicker": {
@@ -1447,6 +1455,53 @@
"openSettingsFolder": "開啟設定資料夾"
}
},
"workflows": {
"title": "工作流",
"contextMenu": {
"sendToComfy": "在 ComfyUI 中開啟",
"updateFromComfy": "從 ComfyUI 更新",
"copyWorkflow": "複製工作流",
"restoreDownloaded": "還原已下載版本"
},
"sendToComfy": {
"success": "工作流已傳送到 ComfyUI",
"failed": "傳送工作流失敗:{message}"
},
"restoreDownloaded": {
"title": "要還原已下載版本嗎?",
"message": "這會捨棄本機修改,並將工作流還原為下載時的版本。",
"confirmButton": "還原",
"restoring": "正在還原工作流...",
"success": "工作流已還原為已下載版本",
"failed": "還原工作流失敗:{message}"
},
"updateFromComfy": {
"success": "已從 ComfyUI 更新工作流",
"noChanges": "ComfyUI 中沒有需要寫回的變更",
"cancelled": "更新已取消",
"failed": "從 ComfyUI 更新失敗:{message}"
},
"copyWorkflow": {
"failed": "複製工作流失敗:{message}"
},
"disabled": {
"title": "工作流管理已關閉",
"description": "啟用後可掃描和管理 ComfyUI 工作流檔案,保留其 CivitAI 中繼資料和預覽,並將它們傳送到執行中的 ComfyUI 中。",
"enableButton": "啟用工作流",
"hint": "您稍後可以在「設定 > 模型庫」中再次關閉此功能。",
"enableFailed": "啟用工作流失敗"
},
"noPaths": {
"title": "找不到工作流資料夾",
"descriptionStandalone": "工作流管理已開啟,但找不到工作流資料夾。請在「設定 > 模型路徑」中加入工作流資料夾,然後重新啟動 LoRA Manager。",
"hintStandalone": "工作流會從 folder_paths.workflows 項目讀取。",
"descriptionComfyUI": "工作流管理已開啟,但未設定工作流資料夾。請在「設定 > 模型庫」中加入工作流資料夾,然後重新載入此頁面。",
"hintComfyUI": "工作流存放在獨立的模型庫資料夾中(folder_paths.workflows);不會影響 ComfyUI 內建的工作流清單。",
"openSettings": "開啟設定",
"openModelPaths": "設定模型資料夾",
"openSettingsFolder": "開啟設定資料夾"
}
},
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
@@ -2272,6 +2327,10 @@
"title": "正在初始化其他模型管理器",
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
},
"workflows": {
"title": "初始化工作流管理器",
"message": "正在掃描並建立工作流快取。這可能需要幾分鐘..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在載入並處理配方,可能需要幾分鐘..."
+121 -1
View File
@@ -209,6 +209,12 @@ class Config:
self.other_root_subtypes: Dict[str, str] = {}
self.other_folder_roots: Dict[str, List[str]] = {}
self.other_roots = self._init_other_paths()
# Workflow roots (ComfyUI workflow JSON library). Opt-in feature:
# empty while ``enable_workflows`` is off. Not a ComfyUI folder_paths
# category — sourced from LoRA Manager's own settings in both modes.
self.workflows_roots: List[str] = []
self.extra_workflows_roots: List[str] = []
self.workflows_roots = self._init_workflow_paths()
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
self.extra_loras_roots: List[str] = []
self.extra_checkpoints_roots: List[str] = []
@@ -265,8 +271,9 @@ class Config:
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
extra_unet = extra_folder_paths.get("unet", []) or []
extra_embedding = extra_folder_paths.get("embeddings", []) or []
extra_workflows = extra_folder_paths.get("workflows", []) or []
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding, extra_workflows]):
return
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
@@ -281,6 +288,8 @@ class Config:
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding
)
if settings_manager.is_workflows_enabled():
self.extra_workflows_roots = self._prepare_lora_paths(extra_workflows)
if self.extra_loras_roots:
logger.info(
@@ -460,6 +469,15 @@ class Config:
self.embeddings_roots or [],
),
}
# Workflow roots live only in our settings (the host has no such
# category); persist them so the whitelist rebuild above does not
# erase the key. Empty lists are still written when the key was
# configured before, so a deliberate removal round-trips.
workflow_configured = self.configured_roots_for("workflow")
if workflow_configured or self.workflows_roots:
target_folder_paths["workflows"] = _append_new_paths(
workflow_configured, self.workflows_roots or []
)
# Persist the other-model roots under their original folder_paths
# keys so library switching round-trips them.
for key, roots in (self.other_folder_roots or {}).items():
@@ -1124,6 +1142,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root))
for root in self.other_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.workflows_roots or []:
preview_roots.update(self._expand_preview_root(root))
# Include extra paths for preview access
for root in self.extra_loras_roots or []:
preview_roots.update(self._expand_preview_root(root))
@@ -1133,6 +1153,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_workflows_roots or []:
preview_roots.update(self._expand_preview_root(root))
if self.recipes_path:
preview_roots.update(self._expand_preview_root(self.recipes_path))
@@ -1596,12 +1618,28 @@ class Config:
self.other_folder_roots,
) = self._prepare_other_paths(other_path_map)
# Workflow roots ride the same library folder_paths structure but are
# not a ComfyUI category; the live list stays empty while the opt-in
# feature is off.
workflow_paths = folder_paths.get("workflows", []) or []
self._remember_configured_paths("workflow", workflow_paths)
try:
from .services.settings_manager import get_settings_manager
workflows_enabled = get_settings_manager().is_workflows_enabled()
except Exception: # pragma: no cover - defensive fallback
workflows_enabled = False
self.workflows_roots = (
self._prepare_lora_paths(workflow_paths) if workflows_enabled else []
)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get("loras", []) or []
extra_checkpoint_paths = extra_paths.get("checkpoints", []) or []
extra_unet_paths = extra_paths.get("unet", []) or []
extra_embedding_paths = extra_paths.get("embeddings", []) or []
extra_workflow_paths = extra_paths.get("workflows", []) or []
filtered_extra_lora_paths = self._filter_overlapping_extra_lora_paths(
self.loras_roots,
@@ -1616,6 +1654,9 @@ class Config:
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding_paths
)
self.extra_workflows_roots = (
self._prepare_lora_paths(extra_workflow_paths) if workflows_enabled else []
)
# Log extra folder paths
if self.extra_loras_roots:
@@ -1809,6 +1850,85 @@ class Config:
logger.warning(f"Error initializing other model paths: {e}")
return []
def _get_configured_workflow_paths(self) -> List[str]:
"""Read the workflows folder_paths entry from the active library.
"workflows" is not a ComfyUI folder_paths category (the host raises
KeyError for unknown keys), so both modes source workflow roots from
LoRA Manager's own settings.
"""
try:
from .services.settings_manager import get_settings_manager
library_config = get_settings_manager().get_active_library()
except Exception:
return []
if not isinstance(library_config, Mapping):
return []
folder_paths_config = library_config.get("folder_paths")
if not isinstance(folder_paths_config, Mapping):
return []
raw_paths = folder_paths_config.get("workflows", [])
if not isinstance(raw_paths, list):
return []
return [path for path in raw_paths if isinstance(path, str) and path.strip()]
def _init_workflow_paths(self) -> List[str]:
"""Initialize and validate workflow roots.
Opt-in feature: the live root list stays empty while
``enable_workflows`` is off, but the configured paths are still
remembered so ``save_folder_paths_to_settings()`` round-trips them.
"""
try:
raw_paths = self._get_configured_workflow_paths()
self._remember_configured_paths("workflow", raw_paths)
from .services.settings_manager import get_settings_manager
if not get_settings_manager().is_workflows_enabled():
return []
unique_paths = self._prepare_lora_paths(raw_paths)
logger.info(
"Found workflow roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
return unique_paths
except Exception as e:
logger.warning(f"Error initializing workflow paths: {e}")
return []
def refresh_workflow_roots(self) -> None:
"""Rebuild workflow roots after the opt-in toggle or paths change.
Called when ``enable_workflows`` or the workflows folder_paths are
updated so the scanner immediately reflects the new folder set
without a full application restart.
"""
self.workflows_roots = self._init_workflow_paths()
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
extra_workflows: List[str] = []
if settings_manager.is_workflows_enabled():
library_config = settings_manager.get_active_library()
if isinstance(library_config, Mapping):
extra_config = library_config.get("extra_folder_paths")
if isinstance(extra_config, Mapping):
raw_extra = extra_config.get("workflows", [])
if isinstance(raw_extra, list):
extra_workflows = [
path
for path in raw_extra
if isinstance(path, str) and path.strip()
]
self.extra_workflows_roots = self._prepare_lora_paths(extra_workflows)
except Exception: # pragma: no cover - defensive fallback
self.extra_workflows_roots = []
self._rebuild_preview_roots()
def _remember_configured_paths(
self, model_type: str, paths: Iterable[str]
) -> None:
+9
View File
@@ -275,6 +275,11 @@ class LoraManager:
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Opt-in but always initialized: an empty-roots scan is trivial, and
# without a cache the /workflows page would sit on the initialization
# screen forever (even the "feature disabled" empty state needs the
# scanner past the initializing check).
workflow_scanner = await ServiceRegistry.get_workflow_scanner()
# Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
@@ -296,6 +301,10 @@ class LoraManager:
other_scanner.initialize_in_background(),
name="other_cache_init",
),
asyncio.create_task(
workflow_scanner.initialize_in_background(),
name="workflow_cache_init",
),
asyncio.create_task(
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
),
+43 -11
View File
@@ -716,6 +716,7 @@ class HealthCheckHandler:
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
"embedding": ServiceRegistry.get_embedding_scanner,
"other": ServiceRegistry.get_other_scanner,
"workflow": ServiceRegistry.get_workflow_scanner,
"recipe": ServiceRegistry.get_recipe_scanner,
}
self._first_run_manager = first_run_manager
@@ -723,13 +724,14 @@ class HealthCheckHandler:
def _active_scanner_getters(
self,
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
getters = self._scanner_getters
if "other" not in getters:
return getters
if get_settings_manager().is_other_models_enabled():
return getters
return {name: getter for name, getter in getters.items() if name != "other"}
"""Drop the opt-in scanners while their features are disabled."""
settings = get_settings_manager()
active = dict(self._scanner_getters)
if not settings.is_other_models_enabled():
active.pop("other", None)
if not settings.is_workflows_enabled():
active.pop("workflow", None)
return active
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
@@ -743,6 +745,7 @@ class HealthCheckHandler:
"checkpoints": ("checkpoint",),
"embeddings": ("embedding",),
"other": ("other",),
"workflows": ("workflow",),
"recipes": ("recipe",),
}
@@ -965,6 +968,7 @@ class DoctorHandler:
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
("other", "Other Models", ServiceRegistry.get_other_scanner),
("workflow", "Workflows", ServiceRegistry.get_workflow_scanner),
)
)
self._app_version_getter = app_version_getter
@@ -972,10 +976,13 @@ class DoctorHandler:
def _active_scanner_factories(
self,
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
if self._settings.is_other_models_enabled():
return self._scanner_factories
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
"""Drop the opt-in scanners while their features are disabled."""
factories = self._scanner_factories
if not self._settings.is_other_models_enabled():
factories = tuple(entry for entry in factories if entry[0] != "other")
if not self._settings.is_workflows_enabled():
factories = tuple(entry for entry in factories if entry[0] != "workflow")
return factories
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
try:
@@ -1852,6 +1859,15 @@ class SettingsHandler:
response_data["other_models_paths_available"] = None
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
response_data["standalone_mode"] = standalone_mode
# Workflow roots are LoRA-Manager-only in BOTH modes (the ComfyUI
# host has no such folder category), so expose them to the
# settings UI regardless of standalone_mode.
get_workflow_paths = getattr(
self._settings, "get_workflow_folder_paths", None
)
response_data["workflow_folder_paths"] = (
get_workflow_paths() if get_workflow_paths else []
)
if standalone_mode:
# Standalone reads its model roots exclusively from
# settings.json, so the Model Paths settings UI needs the
@@ -1958,6 +1974,14 @@ class SettingsHandler:
proxy_changed = False
for key, value in data.items():
if key == "workflow_folder_paths":
# Pseudo-key: replaces only the workflows entry of the
# active library's folder_paths (core keys are host-owned
# in plugin mode and must not be clobbered), then applies
# the new roots live without a restart.
self._settings.set_workflow_folder_paths(value)
continue
if value == self._settings.get(key):
continue
@@ -2418,6 +2442,7 @@ class ServiceRegistryAdapter:
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
get_workflow_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_workflow_scanner
class ModelLibraryHandler:
@@ -2444,6 +2469,8 @@ class ModelLibraryHandler:
return "embedding"
if normalized in VALID_OTHER_CIVITAI_TYPES:
return "other"
if normalized in {"workflow", "workflows", "comfyworkflows"}:
return "workflow"
return None
async def _get_scanner_for_type(self, model_type: str | None):
@@ -2461,6 +2488,11 @@ class ModelLibraryHandler:
if not get_settings_manager().is_other_models_enabled():
return None, None
return normalized_type, await self._service_registry.get_other_scanner()
if normalized_type == "workflow":
# Same opt-in posture as other models.
if not get_settings_manager().is_workflows_enabled():
return None, None
return normalized_type, await self._service_registry.get_workflow_scanner()
return None, None
async def _get_download_history_service(self):
+838
View File
@@ -0,0 +1,838 @@
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from typing import Any, Dict, Optional
import aiohttp
from aiohttp import web
from .base_model_routes import BaseModelRoutes
from .model_route_registrar import ModelRouteRegistrar
from ..config import config
from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.service_registry import ServiceRegistry
from ..services.workflow_link_registry import WorkflowLinkRegistry
from ..services.workflow_service import WorkflowService
from ..utils.constants import WORKFLOW_CIVITAI_TYPES
from ..utils.file_utils import calculate_sha256
from ..utils.metadata_manager import MetadataManager
from ..utils.models import WorkflowMetadata
from ..utils.workflow_analysis import (
MAX_WORKFLOW_JSON_BYTES,
analyze_workflow,
load_workflow_file,
)
from ..utils.workflow_extraction import (
get_workflow_document_path,
load_artifact_workflow_document,
)
logger = logging.getLogger(__name__)
class WorkflowRestoreDownloadError(RuntimeError):
"""Raised when a restore re-download fails before the file is replaced."""
class WorkflowRoutes(BaseModelRoutes):
"""Workflow-specific route controller (ComfyUI workflow JSON resources).
Workflow management is opt-in (``enable_workflows``): while disabled the
page renders an enable-now empty state and CivitAI linking is rejected.
"""
def __init__(self):
"""Initialize Workflow routes with Workflow service"""
super().__init__()
self.template_name = "workflows.html"
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
workflow_scanner = await ServiceRegistry.get_workflow_scanner()
update_service = await ServiceRegistry.get_model_update_service()
self.service = WorkflowService(workflow_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
def setup_routes(self, app: web.Application, prefix: str = "workflows"):
"""Setup Workflow routes"""
# Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'workflows' prefix (includes page route)
super().setup_routes(app, prefix)
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Workflow-specific routes"""
# Workflow info by name
registrar.add_prefixed_route(
'GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_workflow_info
)
# Push a workflow into the ComfyUI graph (plugin mode only). Accepts
# GET as well: the companion browser extension is GET-only by
# convention (see AGENTS.md).
registrar.add_prefixed_route(
'POST', '/api/lm/{prefix}/send-to-comfy', prefix, self.send_to_comfy
)
registrar.add_prefixed_route(
'GET', '/api/lm/{prefix}/send-to-comfy', prefix, self.send_to_comfy
)
# Read the workflow document itself (copy-to-clipboard flow); works in
# standalone mode too since no ComfyUI connection is involved.
registrar.add_prefixed_route(
'GET', '/api/lm/{prefix}/content', prefix, self.get_workflow_content
)
# Write the current ComfyUI graph back into the managed workflow
# document (ComfyUI-side "Update LM-managed workflow" entry point).
registrar.add_prefixed_route(
'PUT', '/api/lm/{prefix}/content', prefix, self.update_workflow_content
)
# ComfyUI-side link-state reporting (which managed workflow is open
# and dirty in which client tab) and the LM-page-triggered write-back
# request/response pair ("Update from ComfyUI" card menu entry).
registrar.add_prefixed_route(
'POST', '/api/lm/{prefix}/comfyui-state', prefix, self.report_comfyui_state
)
registrar.add_prefixed_route(
'GET', '/api/lm/{prefix}/comfyui-state', prefix, self.get_comfyui_state
)
registrar.add_prefixed_route(
'POST', '/api/lm/{prefix}/update-from-comfy', prefix, self.update_from_comfy
)
registrar.add_prefixed_route(
'POST', '/api/lm/{prefix}/collect-result', prefix, self.collect_result
)
# Restore a workflow to its downloaded state (discard local edits).
registrar.add_prefixed_route(
'POST', '/api/lm/{prefix}/restore', prefix, self.restore_workflow
)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for workflows (opt-in gated)."""
if not self._settings.is_workflows_enabled():
return False
return (model_type or "").strip().lower() in WORKFLOW_CIVITAI_TYPES
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
return "Workflows or ComfyWorkflows"
def _find_model_file(self, files):
"""Prefer a single-workflow Config .json, then an Archive .zip.
Mirrors CivitAI's own default pick for Workflow resources
(resource-file.service.ts pickDefaultResourceFile); falls back to the
base-class weight-file behaviour for anything else.
"""
def rank(file_info):
file_type = file_info.get("type")
name = (file_info.get("name") or "").lower()
if file_type == "Config" and name.endswith(".json"):
return 0
if file_type == "Archive" and name.endswith(".zip"):
return 1
return 2
candidates = [f for f in files if rank(f) < 2]
if candidates:
candidates.sort(key=lambda f: (rank(f), f.get("id", 0)))
return candidates[0]
return super()._find_model_file(files)
def _get_page_context_provider(self):
"""Expose the opt-in feature state to the Workflows page template."""
return self._page_context_for_workflows
def _page_context_for_workflows(self, request: web.Request) -> Dict[str, Any]:
if not self._settings.is_workflows_enabled():
return {"workflows_disabled": True, "workflows_no_paths": False}
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
context = {
"workflows_disabled": False,
"workflows_no_paths": not bool(config.workflows_roots),
"standalone_mode": standalone_mode,
}
if standalone_mode:
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
return context
async def get_workflow_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific workflow by name"""
try:
name = request.match_info.get('name', '')
workflow_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
if workflow_info:
return web.json_response(workflow_info)
else:
return web.json_response({"error": "Workflow not found"}, status=404)
except Exception as e:
logger.error(f"Error in get_workflow_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
async def send_to_comfy(self, request: web.Request) -> web.Response:
"""Load a library workflow into the running ComfyUI graph.
Reads the workflow JSON and broadcasts it over the ``lm_load_workflow``
websocket channel; the ComfyUI-side extension picks it up and calls
``loadGraphData`` (same channel as recipe send-workflow).
"""
try:
if os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1":
return web.json_response(
{"success": False, "error": "Standalone Mode Active"}, status=400
)
if not self._settings.is_workflows_enabled():
return web.json_response(
{"success": False, "error": "Workflow management is disabled"},
status=400,
)
if request.method == "POST":
payload = await self._read_json_body(request)
else:
payload = dict(request.query)
file_path = (payload.get("file_path") or "").strip()
if not file_path:
return web.json_response(
{"success": False, "error": "file_path is required"}, status=400
)
_require_path_in_library_roots(
file_path, self.service.scanner, label="File path"
)
workflow = await self._load_workflow_payload(file_path)
if workflow is None:
return web.json_response(
{
"success": False,
"error": "not_a_workflow",
"message": "File is missing or is not a ComfyUI workflow",
},
status=404,
)
# Lazy import: standalone mode replaces the ``server`` module with
# a mock; the standalone check above already guards UX.
from server import PromptServer # pyright: ignore[reportMissingImports]
PromptServer.instance.send_sync(
"lm_load_workflow",
{
"workflow": workflow,
"name": os.path.splitext(os.path.basename(file_path))[0],
# Provenance marker: the ComfyUI-side extension stamps this
# onto the loaded graph so its "Update LM-managed workflow"
# entry point can write edits back to the right document.
"lm_source": {
"file_path": file_path,
"sha256": await self._document_sha256(file_path),
},
},
)
return web.json_response({"success": True, "sent": True})
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
logger.error("Error sending workflow to ComfyUI: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_workflow_content(self, request: web.Request) -> web.Response:
"""Return the extracted workflow document for a library entry.
Used by the copy-to-clipboard flow; unlike send-to-comfy it does not
require a running ComfyUI, so it works in standalone mode as well.
"""
try:
if not self._settings.is_workflows_enabled():
return web.json_response(
{"success": False, "error": "Workflow management is disabled"},
status=400,
)
file_path = (request.query.get("file_path") or "").strip()
if not file_path:
return web.json_response(
{"success": False, "error": "file_path is required"}, status=400
)
_require_path_in_library_roots(
file_path, self.service.scanner, label="File path"
)
workflow = await self._load_workflow_payload(file_path)
if workflow is None:
return web.json_response(
{
"success": False,
"error": "not_a_workflow",
"message": "File is missing or is not a ComfyUI workflow",
},
status=404,
)
return web.json_response({"success": True, "workflow": workflow})
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
logger.error("Error reading workflow content: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _read_json_body(self, request: web.Request) -> Dict[str, Any]:
try:
payload = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
async def _load_workflow_payload(self, file_path: str):
"""Read the extracted workflow document off the event loop.
``.json`` artifacts are read directly; containers (``.zip``/image
files) resolve to their derived ``.workflow.json`` sidecar, which is
materialized on demand when missing.
"""
return await asyncio.to_thread(load_artifact_workflow_document, file_path)
async def _document_sha256(self, file_path: str) -> Optional[str]:
"""SHA256 of the extracted workflow document on disk.
Callers must materialize the document first (``_load_workflow_payload``
does). Returns ``None`` when the document is missing/unreadable.
"""
document_path = get_workflow_document_path(file_path)
try:
if not os.path.exists(document_path):
return None
except OSError:
return None
try:
return await calculate_sha256(document_path) or None
except OSError:
return None
async def update_workflow_content(self, request: web.Request) -> web.Response:
"""Write the current ComfyUI graph back into a managed workflow.
The ComfyUI-side extension calls this with the serialized graph when
the user picks "Update LM-managed workflow". The write targets the
workflow document: ``.json`` artifacts are rewritten in place, while
container artifacts (``.zip``/images) keep their original bytes (the
SHA256 is their CivitAI identity) and receive the edited graph in the
derived ``.workflow.json`` sidecar.
Optimistic concurrency: when ``base_sha256`` (the document hash the
client saw when the workflow was opened) no longer matches the
document on disk, the write is rejected with 409 unless ``force`` is
set — someone else (re-download, update check) changed it meanwhile.
"""
try:
if not self._settings.is_workflows_enabled():
return web.json_response(
{"success": False, "error": "Workflow management is disabled"},
status=400,
)
payload = await self._read_json_body(request)
file_path = (payload.get("file_path") or "").strip()
workflow = payload.get("workflow")
if not file_path:
return web.json_response(
{"success": False, "error": "file_path is required"}, status=400
)
if not isinstance(workflow, dict):
return web.json_response(
{"success": False, "error": "workflow is required"}, status=400
)
_require_path_in_library_roots(
file_path, self.service.scanner, label="File path"
)
# The provenance marker the client stamped for bookkeeping must
# never land in the library document.
extra = workflow.get("extra")
if isinstance(extra, dict):
extra.pop("lm_source", None)
analysis = analyze_workflow(workflow)
if analysis is None:
return web.json_response(
{"success": False, "error": "workflow is not a ComfyUI graph document"},
status=400,
)
text = json.dumps(workflow, ensure_ascii=False)
if len(text.encode("utf-8")) > MAX_WORKFLOW_JSON_BYTES:
return web.json_response(
{"success": False, "error": "workflow exceeds the size limit"},
status=400,
)
document_path = get_workflow_document_path(file_path)
base_sha256 = str(payload.get("base_sha256") or "").strip().lower()
force = bool(payload.get("force"))
if base_sha256 and not force and os.path.exists(document_path):
current_sha256 = await calculate_sha256(document_path)
if current_sha256 and current_sha256.lower() != base_sha256:
return web.json_response(
{
"success": False,
"error": "conflict",
"current_sha256": current_sha256,
},
status=409,
)
def _write_document() -> None:
os.makedirs(os.path.dirname(document_path), exist_ok=True)
fd, temp_path = tempfile.mkstemp(
dir=os.path.dirname(document_path), suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
os.replace(temp_path, document_path)
except BaseException:
try:
os.unlink(temp_path)
except OSError:
pass
raise
await asyncio.to_thread(_write_document)
new_sha256 = hashlib.sha256(text.encode("utf-8")).hexdigest()
await self._refresh_metadata_after_write(
file_path, analysis, new_sha256
)
return web.json_response(
{"success": True, "file_path": file_path, "sha256": new_sha256}
)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
logger.error("Error updating workflow content: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _refresh_metadata_after_write(
self, file_path: str, analysis, document_sha256: str
) -> None:
"""Re-sync sidecar metadata and caches after a write-back.
Analysis fields (node classes, model refs, ...) always follow the new
document. The CivitAI-facing artifact hash/size/modified only move for
bare ``.json`` artifacts, whose bytes ARE the document; container
artifacts stay byte-identical.
"""
try:
metadata, should_skip = await MetadataManager.load_metadata(
file_path, WorkflowMetadata
)
if should_skip or metadata is None:
return
metadata.workflow_format = analysis.workflow_format
metadata.node_count = analysis.node_count
metadata.node_classes = analysis.node_classes
metadata.model_refs = analysis.model_refs
metadata.civitai_refs = analysis.civitai_refs
if os.path.splitext(file_path)[1].lower() == ".json":
metadata.sha256 = document_sha256
real_path = os.path.realpath(file_path)
metadata.size = os.path.getsize(real_path)
metadata.modified = os.path.getmtime(real_path)
await MetadataManager.save_metadata(file_path, metadata)
scanner = getattr(self.service, "scanner", None)
if scanner is not None:
await scanner.sync_cache_from_metadata(
file_path, metadata.to_dict()
)
except Exception:
logger.warning(
"Failed to refresh workflow metadata after write-back for %s",
file_path,
exc_info=True,
)
# ------------------------------------------------------------------
# ComfyUI link-state reporting + LM-page-triggered write-back
# ------------------------------------------------------------------
@staticmethod
def _is_standalone() -> bool:
return os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
@staticmethod
def _active_client_ids() -> set:
"""Live ComfyUI websocket client ids; empty when unavailable."""
try:
from server import PromptServer # pyright: ignore[reportMissingImports]
return set(PromptServer.instance.sockets.keys())
except Exception:
return set()
async def report_comfyui_state(self, request: web.Request) -> web.Response:
"""ComfyUI client reports its active graph's LM link state.
Body: ``{client_id, state}`` where ``state`` is
``{file_path, sha256, dirty, name}`` or ``null`` (active graph is not
LM-linked). In standalone mode there are no ComfyUI clients; the
report is accepted and dropped.
"""
if self._is_standalone():
return web.json_response({"success": True})
payload = await self._read_json_body(request)
client_id = payload.get("client_id")
if not isinstance(client_id, str) or not client_id:
return web.json_response(
{"success": False, "error": "client_id is required"}, status=400
)
state = payload.get("state")
registry = await WorkflowLinkRegistry.get_instance()
await registry.report_state(
client_id, state if isinstance(state, dict) else None
)
return web.json_response({"success": True})
async def get_comfyui_state(self, request: web.Request) -> web.Response:
"""List LM-linked workflows currently open in live ComfyUI tabs."""
if self._is_standalone():
return web.json_response({"success": True, "states": []})
registry = await WorkflowLinkRegistry.get_instance()
states = await registry.get_states(self._active_client_ids())
return web.json_response({"success": True, "states": states})
async def update_from_comfy(self, request: web.Request) -> web.Response:
"""Ask the ComfyUI tab holding *file_path* to write its graph back.
The LM web UI's "Update from ComfyUI" card menu entry calls this; the
request blocks until the ComfyUI client performs the write-back (via
the regular ``PUT .../content`` endpoint) and reports the outcome, or
a timeout elapses.
"""
try:
if self._is_standalone():
return web.json_response(
{"success": False, "error": "Standalone Mode Active"}, status=400
)
if not self._settings.is_workflows_enabled():
return web.json_response(
{"success": False, "error": "Workflow management is disabled"},
status=400,
)
payload = await self._read_json_body(request)
file_path = (payload.get("file_path") or "").strip()
if not file_path:
return web.json_response(
{"success": False, "error": "file_path is required"}, status=400
)
_require_path_in_library_roots(
file_path, self.service.scanner, label="File path"
)
registry = await WorkflowLinkRegistry.get_instance()
sid = await registry.find_client_for(file_path, self._active_client_ids())
if sid is None:
return web.json_response(
{
"success": False,
"error": "not_open",
"message": "Workflow is not open in any connected ComfyUI tab",
},
status=404,
)
request_id, future = registry.begin_collect()
from server import PromptServer # pyright: ignore[reportMissingImports]
PromptServer.instance.send_sync(
"lm_collect_workflow",
{"request_id": request_id, "file_path": file_path},
sid=sid,
)
result = await registry.await_collect(request_id, future, timeout=20.0)
status = 200 if result.get("success") else 500
if result.get("error") in ("no_changes", "cancelled"):
status = 200
elif result.get("error") == "timeout":
status = 504
return web.json_response(result, status=status)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
logger.error("Error collecting workflow from ComfyUI: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def collect_result(self, request: web.Request) -> web.Response:
"""ComfyUI client reports the outcome of a collect request."""
if self._is_standalone():
return web.json_response({"success": False, "error": "Standalone Mode Active"}, status=400)
payload = await self._read_json_body(request)
request_id = payload.get("request_id")
if not isinstance(request_id, str) or not request_id:
return web.json_response(
{"success": False, "error": "request_id is required"}, status=400
)
result = {
key: payload[key]
for key in ("success", "error", "sha256", "file_path")
if key in payload
}
registry = await WorkflowLinkRegistry.get_instance()
resolved = await registry.resolve_collect(request_id, result)
return web.json_response({"success": True, "resolved": resolved})
# ------------------------------------------------------------------
# Restore to downloaded state
# ------------------------------------------------------------------
async def restore_workflow(self, request: web.Request) -> web.Response:
"""Restore a workflow to its downloaded state, discarding local edits.
Container artifacts (``.zip``/images) were never modified — write-backs
only touch the derived ``.workflow.json`` sidecar — so restore discards
the sidecar and re-extracts from the pristine archive. Bare ``.json``
artifacts are rewritten in place by write-backs, so restore
re-downloads the recorded CivitAI version file and replaces the local
copy (after validating the download and verifying its hash).
"""
try:
if not self._settings.is_workflows_enabled():
return web.json_response(
{"success": False, "error": "Workflow management is disabled"},
status=400,
)
payload = await self._read_json_body(request)
file_path = (payload.get("file_path") or "").strip()
if not file_path:
return web.json_response(
{"success": False, "error": "file_path is required"}, status=400
)
_require_path_in_library_roots(
file_path, self.service.scanner, label="File path"
)
if not os.path.exists(file_path):
return web.json_response(
{"success": False, "error": "Workflow file not found"}, status=404
)
if os.path.splitext(file_path)[1].lower() == ".json":
return await self._restore_json_artifact(file_path)
return await self._restore_container_artifact(file_path)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
logger.error("Error restoring workflow: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _restore_container_artifact(self, file_path: str) -> web.Response:
"""Discard the derived document and re-extract from the archive."""
document_path = get_workflow_document_path(file_path)
def _discard_derived() -> None:
try:
os.unlink(document_path)
except FileNotFoundError:
pass
await asyncio.to_thread(_discard_derived)
document = await asyncio.to_thread(load_artifact_workflow_document, file_path)
if document is None:
return web.json_response(
{
"success": False,
"error": "not_a_workflow",
"message": "Archive does not contain a ComfyUI workflow",
},
status=404,
)
analysis = analyze_workflow(document)
doc_sha = hashlib.sha256(
json.dumps(document, ensure_ascii=False).encode("utf-8")
).hexdigest()
await self._refresh_metadata_after_write(file_path, analysis, doc_sha)
return web.json_response(
{"success": True, "file_path": file_path, "restored": "extracted"}
)
async def _restore_json_artifact(self, file_path: str) -> web.Response:
"""Re-download the recorded CivitAI file over the local copy."""
metadata, should_skip = await MetadataManager.load_metadata(
file_path, WorkflowMetadata
)
version_info = (
metadata.civitai
if metadata is not None and not should_skip and metadata.from_civitai
else None
)
if not isinstance(version_info, dict) or not version_info:
return web.json_response(
{
"success": False,
"error": "no_source",
"message": "Workflow has no recorded CivitAI source to restore from",
},
status=400,
)
file_info = self._find_downloaded_file_info(version_info, file_path)
download_url = (file_info or {}).get("downloadUrl") or version_info.get(
"downloadUrl"
)
if not download_url:
return web.json_response(
{
"success": False,
"error": "no_source",
"message": "Recorded CivitAI version has no download URL",
},
status=400,
)
expected_sha256 = (
(((file_info or {}).get("hashes") or {}).get("SHA256") or "").lower()
or None
)
try:
new_sha256 = await self._download_and_replace(
download_url, file_path, expected_sha256=expected_sha256
)
except WorkflowRestoreDownloadError as exc:
return web.json_response(
{
"success": False,
"error": "download_failed",
"message": str(exc),
},
status=502,
)
document = await asyncio.to_thread(load_workflow_file, file_path)
if document is None:
# Defensive: the temp file was validated before the replace, so
# this can only fail on a race with an external writer.
return web.json_response(
{
"success": False,
"error": "not_a_workflow",
"message": "Restored file is not a ComfyUI workflow",
},
status=422,
)
analysis = analyze_workflow(document)
await self._refresh_metadata_after_write(file_path, analysis, new_sha256)
return web.json_response(
{
"success": True,
"file_path": file_path,
"restored": "downloaded",
"sha256": new_sha256,
}
)
@staticmethod
def _find_downloaded_file_info(
version_info: Dict[str, Any], file_path: str
) -> Optional[Dict[str, Any]]:
"""Match the stored version's file entry to the local artifact."""
files = [f for f in version_info.get("files") or [] if isinstance(f, dict)]
if not files:
return None
basename = os.path.basename(file_path)
for file_info in files:
if file_info.get("name") == basename:
return file_info
return files[0] if len(files) == 1 else None
async def _download_and_replace(
self,
download_url: str,
file_path: str,
*,
expected_sha256: Optional[str] = None,
) -> str:
"""Download *download_url* and atomically replace *file_path*.
The download lands in a temp file next to the target and is only
swapped in after it parses as a workflow and (when known) matches the
recorded SHA256. Returns the downloaded file's SHA256.
"""
api_key = (self._settings.get("civitai_api_key", "") or "").strip()
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
timeout = aiohttp.ClientTimeout(total=600)
fd, temp_path = tempfile.mkstemp(
dir=os.path.dirname(file_path), suffix=".download"
)
os.close(fd)
try:
async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:
async with session.get(download_url) as response:
if response.status != 200:
raise WorkflowRestoreDownloadError(
f"Download failed with HTTP {response.status}"
)
written = 0
with open(temp_path, "wb") as handle:
async for chunk in response.content.iter_chunked(1 << 20):
written += len(chunk)
if written > MAX_WORKFLOW_JSON_BYTES:
raise WorkflowRestoreDownloadError(
"Downloaded file exceeds the workflow size limit"
)
handle.write(chunk)
if load_workflow_file(temp_path) is None:
raise WorkflowRestoreDownloadError(
"Downloaded file is not a ComfyUI workflow"
)
new_sha256 = await calculate_sha256(temp_path)
if expected_sha256 and new_sha256.lower() != expected_sha256:
raise WorkflowRestoreDownloadError(
"Downloaded file does not match the recorded SHA256"
)
await asyncio.to_thread(os.replace, temp_path, file_path)
return new_sha256
except WorkflowRestoreDownloadError:
try:
os.unlink(temp_path)
except OSError:
pass
raise
except Exception as exc:
try:
os.unlink(temp_path)
except OSError:
pass
raise WorkflowRestoreDownloadError(str(exc)) from exc
+108 -1
View File
@@ -22,6 +22,7 @@ from ..utils.models import (
CheckpointMetadata,
EmbeddingMetadata,
OtherModelMetadata,
WorkflowMetadata,
)
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
@@ -32,6 +33,7 @@ from ..utils.constants import (
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
WORKFLOW_CIVITAI_TYPES,
)
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.paid_access import (
@@ -109,6 +111,18 @@ def _other_models_disabled_response() -> Dict[str, Any]:
}
def _workflows_disabled_response() -> Dict[str, Any]:
"""Structured rejection when Workflow management is turned off."""
return {
"success": False,
"error": (
"Workflow management is disabled. Enable it in "
"Settings > Library before downloading workflow files."
),
"reason": "workflows_disabled",
}
@dataclass
class _PathSlot:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -400,6 +414,8 @@ class DownloadManager:
return await ServiceRegistry.get_embedding_scanner()
if model_type == "other":
return await ServiceRegistry.get_other_scanner()
if model_type == "workflow":
return await ServiceRegistry.get_workflow_scanner()
if model_type == "lora":
return await self._get_lora_scanner()
raise ValueError(f'Unknown model type "{model_type}"')
@@ -486,6 +502,37 @@ class DownloadManager:
return file_info
@staticmethod
def _select_workflow_download_file(
files: Any,
) -> Optional[Dict[str, Any]]:
"""Pick the workflow artifact within a workflow version's file list.
Workflow versions ship no weight files, so the generic
primary/weights selection finds nothing. The ranking mirrors
CivitAI's own default pick for Workflow resources (a single-workflow
Config .json first, then an Archive .zip, then an image carrying an
embedded workflow), with CivitAI's primary flag breaking ties.
"""
def rank(file_info: Dict[str, Any]) -> int:
name = str(file_info.get("name") or "").lower()
if name.endswith(".json"):
return 0
if name.endswith(".zip"):
return 1
if name.endswith((".png", ".webp")):
return 2
return 3
candidates = [f for f in files or [] if isinstance(f, dict)]
if not candidates:
return None
candidates.sort(
key=lambda f: (not bool(f.get("primary")), rank(f), f.get("id") or 0)
)
return candidates[0]
async def _find_local_file_entry(
self,
model_type: str,
@@ -1196,6 +1243,8 @@ class DownloadManager:
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "other":
return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "workflow":
return WorkflowMetadata.from_civitai_info(version_info, file_info, save_path)
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
@@ -1708,6 +1757,17 @@ class DownloadManager:
"error": "Model version already exists in other library",
}
# Check workflow scanner (opt-in feature)
if get_settings_manager().is_workflows_enabled():
workflow_scanner = await ServiceRegistry.get_workflow_scanner()
if await workflow_scanner.check_model_version_exists(
model_version_id
):
return {
"success": False,
"error": "Model version already exists in workflow library",
}
# Use CivArchive provider directly when source is 'civarchive'
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
if source == "civarchive":
@@ -1823,6 +1883,10 @@ class DownloadManager:
if not get_settings_manager().is_other_models_enabled():
return _other_models_disabled_response()
model_type = "other"
elif model_type_from_info in WORKFLOW_CIVITAI_TYPES:
if not get_settings_manager().is_workflows_enabled():
return _workflows_disabled_response()
model_type = "workflow"
else:
return {
"success": False,
@@ -2002,6 +2066,13 @@ class DownloadManager:
"success": False,
"error": "Model version already exists in other library",
}
elif model_type == "workflow":
workflow_scanner = await ServiceRegistry.get_workflow_scanner()
if await workflow_scanner.check_model_version_exists(version_id):
return {
"success": False,
"error": "Model version already exists in workflow library",
}
# Handle use_default_paths
if use_default_paths:
@@ -2103,6 +2174,20 @@ class DownloadManager:
"reason": reason,
}
save_dir = default_path
elif model_type == "workflow":
# Workflows have no default-root setting: the first
# configured workflow root is the download target.
workflow_scanner = await ServiceRegistry.get_workflow_scanner()
workflow_roots = workflow_scanner.get_model_roots()
if not workflow_roots:
return {
"success": False,
"error": (
"No workflow folder configured. Add one in "
"Settings > Library before downloading workflows."
),
}
save_dir = workflow_roots[0]
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -2194,6 +2279,14 @@ class DownloadManager:
)
# Fallback to primary file if no match found
if not file_info and model_type == "workflow":
file_info = self._select_workflow_download_file(files)
if file_info:
logger.debug(
"[download] Workflow file selected: id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
if not file_info:
logger.debug("[download] Looking for primary file as fallback")
# Prefer a weights-type file CivitAI marked primary; then any
@@ -2335,6 +2428,11 @@ class DownloadManager:
version_info, file_info, save_path
)
logger.info(f"Creating OtherModelMetadata for {file_name}")
elif model_type == "workflow":
metadata = WorkflowMetadata.from_civitai_info(
version_info, file_info, save_path
)
logger.info(f"Creating WorkflowMetadata for {file_name}")
else:
return {
"success": False,
@@ -2553,6 +2651,8 @@ class DownloadManager:
scanner = await ServiceRegistry.get_embedding_scanner()
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
elif model_type == "workflow":
scanner = await ServiceRegistry.get_workflow_scanner()
except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
@@ -3023,7 +3123,11 @@ class DownloadManager:
# 4. Handle archive extraction and prepare per-file metadata
actual_file_paths = [save_path]
if zipfile.is_zipfile(save_path):
# Workflow downloads never extract archives: the zip IS the
# artifact, kept byte-identical so its SHA256 stays the CivitAI
# identity; the workflow document is materialized into a derived
# sidecar by the scanner instead.
if model_type != "workflow" and zipfile.is_zipfile(save_path):
supported_extensions = self._get_supported_extensions_for_type(
model_type
)
@@ -3087,6 +3191,9 @@ class DownloadManager:
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
logger.info(f"Updating other-model cache for {actual_file_paths[0]}")
elif model_type == "workflow":
scanner = await ServiceRegistry.get_workflow_scanner()
logger.info(f"Updating workflow cache for {actual_file_paths[0]}")
adjust_cached_entry = (
getattr(scanner, "adjust_cached_entry", None)
+4 -1
View File
@@ -162,13 +162,16 @@ class FirstRunManager:
"checkpoints": ServiceRegistry.get_checkpoint_scanner,
"embeddings": ServiceRegistry.get_embedding_scanner,
"other": ServiceRegistry.get_other_scanner,
"workflows": ServiceRegistry.get_workflow_scanner,
}
def _active_scanner_getters(self) -> Dict[str, Callable[[], Awaitable[Any]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
"""Drop the opt-in scanners while their features are disabled."""
getters = self._scanner_getters()
if "other" in getters and not self._settings.is_other_models_enabled():
getters.pop("other")
if "workflows" in getters and not self._settings.is_workflows_enabled():
getters.pop("workflows")
return getters
# ------------------------------------------------------------------
+20
View File
@@ -14,6 +14,7 @@ from ..services.pending_delete_service import get_pending_delete_service
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
from ..utils.workflow_extraction import WORKFLOW_DOCUMENT_SUFFIX
logger = logging.getLogger(__name__)
@@ -55,20 +56,28 @@ async def delete_model_artifacts(
model_path = os.path.join(target_dir, main_file)
sidecar_dir = get_sidecar_dir(model_path)
patterns = [os.path.basename(get_metadata_path(model_path))]
# Derived workflow document (workflow artifacts only; absent elsewhere).
patterns.append(f"{file_name}{WORKFLOW_DOCUMENT_SUFFIX}")
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{file_name}{ext}")
deleted: List[str] = []
main_path = model_path.replace(os.sep, "/")
deleted_paths: set[str] = set()
if os.path.exists(main_path):
os.remove(main_path)
deleted.append(main_path)
deleted_paths.add(os.path.normcase(os.path.abspath(main_path)))
else:
logger.warning("Model file not found: %s", main_file)
for pattern in patterns:
path = os.path.join(sidecar_dir, pattern)
# The model file can double as its own preview (workflow image
# artifacts); never delete the same path twice.
if os.path.normcase(os.path.abspath(path)) in deleted_paths:
continue
if os.path.exists(path):
try:
os.remove(path)
@@ -467,14 +476,24 @@ class ModelLifecycleService:
(target_dir, f"{old_file_name}{old_extension}"),
(sidecar_dir, metadata_filename),
(sidecar_dir, f"{metadata_filename}.bak"),
# Derived workflow document (workflow artifacts only).
(sidecar_dir, f"{old_file_name}{WORKFLOW_DOCUMENT_SUFFIX}"),
]
for ext in PREVIEW_EXTENSIONS:
patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
existing_files: List[tuple[str, str]] = []
seen_paths: set[str] = set()
for pattern_dir, pattern in patterns:
path = os.path.join(pattern_dir, pattern)
# An image workflow artifact doubles as its own preview: the same
# path then appears under both the main-file and preview patterns
# and must only be renamed once.
dedupe_key = os.path.normcase(os.path.abspath(path))
if dedupe_key in seen_paths:
continue
if os.path.exists(path):
seen_paths.add(dedupe_key)
existing_files.append((path, pattern))
metadata_path = get_metadata_path(file_path)
@@ -585,6 +604,7 @@ class ModelLifecycleService:
known_suffixes = [
".metadata.json.bak",
".metadata.json",
WORKFLOW_DOCUMENT_SUFFIX,
".safetensors",
*PREVIEW_EXTENSIONS,
]
+34 -1
View File
@@ -101,6 +101,7 @@ PAGE_TYPE_MAP = {
'checkpoint': 'checkpoints',
'embedding': 'embeddings',
'other': 'other',
'workflow': 'workflows',
}
# Case-insensitive cache matching is a Windows-only safety net (NTFS treats
@@ -629,6 +630,7 @@ def _walk_root_for_reconcile(
expected_folder_of: Callable[[str], str],
walk_path: Optional[str] = None,
unreachable: Optional[_UnreachablePaths] = None,
filename_filter: Optional[Callable[[str], bool]] = None,
) -> _RootWalkResult:
"""Walk one model root and classify every model file found.
@@ -711,6 +713,8 @@ def _walk_root_for_reconcile(
ext = os.path.splitext(file)[1].lower()
if ext not in file_extensions:
continue
if filename_filter is not None and not filename_filter(file):
continue
result.files_seen += 1
files_since_report += 1
@@ -1211,6 +1215,20 @@ class ModelScanner:
if hash_status:
entry['hash_status'] = hash_status
# Workflow analysis fields (workflows only): copied through whenever
# the source carries them so the cache and API expose the parsed graph
# summary; other model types never define these keys.
for key in (
'workflow_format',
'node_count',
'node_classes',
'model_refs',
'civitai_refs',
):
value = get_value(key, None)
if value is not None:
entry[key] = value
return entry
def _ensure_license_flags(self, entry: Dict[str, Any]) -> None:
@@ -1689,6 +1707,8 @@ class ModelScanner:
ext = os.path.splitext(entry.name)[1].lower()
if ext not in self.file_extensions:
continue
if not self._is_indexable_filename(entry.name):
continue
real_file_path = os.path.realpath(entry.path)
if real_file_path in seen_real_files:
continue
@@ -1849,7 +1869,10 @@ class ModelScanner:
# If force refresh is requested, initialize the cache directly
if force_refresh:
if rebuild_cache:
# A reconcile diffs against the existing cache; when there is no
# cache yet (e.g. a scan fired while background initialization is
# still deferred), a full initialization is the only option.
if rebuild_cache or self._cache is None:
self._last_reconcile_summary = None
await self._initialize_cache()
else:
@@ -2700,6 +2723,7 @@ class ModelScanner:
report_progress=report_progress,
expected_folder_of=expected_folder_of,
unreachable=unreachable,
filename_filter=self._is_indexable_filename,
)
finally:
tracker.finish_root(root_path)
@@ -3417,6 +3441,15 @@ class ModelScanner:
"""Hook for subclasses: adjust metadata during scanning"""
return metadata
def _is_indexable_filename(self, filename: str) -> bool:
"""Hook for subclasses: whether a matching-extension file is indexable.
Scanners whose extension set collides with sidecar formats (e.g. the
workflow scanner's ``.json`` vs ``.metadata.json``) override this to
keep sidecars out of the library walk.
"""
return True
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
return entry
+6 -1
View File
@@ -123,10 +123,12 @@ def register_default_model_types():
from ..services.checkpoint_service import CheckpointService
from ..services.embedding_service import EmbeddingService
from ..services.other_model_service import OtherModelService
from ..services.workflow_service import WorkflowService
from ..routes.lora_routes import LoraRoutes
from ..routes.checkpoint_routes import CheckpointRoutes
from ..routes.embedding_routes import EmbeddingRoutes
from ..routes.other_routes import OtherRoutes
from ..routes.workflow_routes import WorkflowRoutes
# Register LoRA model type
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
@@ -138,4 +140,7 @@ def register_default_model_types():
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
# Register Other model type (VAE, upscaler, text encoder, ...)
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
# Register Workflow model type (ComfyUI workflow JSON; opt-in feature)
ModelServiceFactory.register_model_type('workflow', WorkflowService, WorkflowRoutes)
+9
View File
@@ -39,6 +39,7 @@ from typing import (
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.sidecar_paths import get_metadata_path, get_sidecar_dir
from ..utils.workflow_extraction import WORKFLOW_DOCUMENT_SUFFIX
from ..utils import settings_paths
logger = logging.getLogger(__name__)
@@ -706,12 +707,20 @@ class PendingDeleteService:
# alongside mode, the centralized mirror otherwise).
sidecar_dir = get_sidecar_dir(model_path)
patterns = [os.path.basename(get_metadata_path(model_path))]
# Derived workflow document (workflow artifacts only; absent elsewhere).
patterns.append(f"{file_name}{WORKFLOW_DOCUMENT_SUFFIX}")
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{file_name}{ext}")
seen = {os.path.normcase(main_path)}
for pattern in patterns:
path = os.path.abspath(os.path.join(sidecar_dir, pattern))
# An image workflow artifact doubles as its own preview: it then
# matches both the main file and a preview pattern — stage it once.
if os.path.normcase(path) in seen:
continue
if os.path.exists(path):
seen.add(os.path.normcase(path))
artifacts.append(path)
return artifacts
+51
View File
@@ -47,6 +47,30 @@ class PersistedCacheData:
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
def _json_list_or_none(value: Any) -> Optional[str]:
"""Encode a list field as JSON for storage; keep None as NULL."""
if value is None:
return None
if isinstance(value, str):
return value
return json.dumps(value)
def _json_list_from_row(value: Any) -> List[Any]:
"""Decode a JSON list column back into a list (empty on bad data)."""
if not value:
return []
if isinstance(value, list):
return value
try:
decoded = json.loads(value)
except (json.JSONDecodeError, TypeError):
return []
return decoded if isinstance(decoded, list) else []
class PersistentModelCache:
"""Persist core model metadata and hash index data in SQLite."""
@@ -88,6 +112,11 @@ class PersistentModelCache:
"hf_url",
"source_model_id",
"source_version_id",
"workflow_format",
"node_count",
"node_classes",
"model_refs",
"civitai_refs",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {}
@@ -237,6 +266,12 @@ class PersistentModelCache:
"source_model_id": row["source_model_id"] or "",
"source_version_id": row["source_version_id"] or "",
}
if model_type == "workflow":
item["workflow_format"] = row["workflow_format"] or ""
item["node_count"] = row["node_count"] or 0
item["node_classes"] = _json_list_from_row(row["node_classes"])
item["model_refs"] = _json_list_from_row(row["model_refs"])
item["civitai_refs"] = _json_list_from_row(row["civitai_refs"])
# Legacy rows only carry `hf_url`; derive the canonical pair so
# every consumer sees the same shape.
normalize_metadata_source(item)
@@ -661,6 +696,11 @@ class PersistentModelCache:
hf_url TEXT DEFAULT '',
source_model_id TEXT DEFAULT '',
source_version_id TEXT DEFAULT '',
workflow_format TEXT DEFAULT '',
node_count INTEGER DEFAULT 0,
node_classes TEXT,
model_refs TEXT,
civitai_refs TEXT,
PRIMARY KEY (model_type, file_path)
);
@@ -734,6 +774,12 @@ class PersistentModelCache:
"source_version_id": "TEXT DEFAULT ''",
"autov3": "TEXT",
"quantization": "TEXT",
# Workflow-specific analysis fields (JSON lists for the *_refs classes).
"workflow_format": "TEXT DEFAULT ''",
"node_count": "INTEGER DEFAULT 0",
"node_classes": "TEXT",
"model_refs": "TEXT",
"civitai_refs": "TEXT",
}
for column, definition in required_columns.items():
@@ -831,6 +877,11 @@ class PersistentModelCache:
item.get("hf_url") or "",
item.get("source_model_id") or "",
item.get("source_version_id") or "",
item.get("workflow_format") or "",
int(item.get("node_count") or 0),
_json_list_or_none(item.get("node_classes")),
_json_list_or_none(item.get("model_refs")),
_json_list_or_none(item.get("civitai_refs")),
)
def _insert_model_sql(self) -> str:
+21
View File
@@ -356,6 +356,27 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
async def get_workflow_scanner(cls):
"""Get or create Workflow scanner instance"""
service_name = "workflow_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .workflow_scanner import WorkflowScanner
scanner = await WorkflowScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
def clear_services(cls):
"""Clear all registered services - mainly for testing"""
+62
View File
@@ -130,6 +130,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
# for download until the user turns the feature on.
"enable_other_models": False,
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
# Workflow management is likewise opt-in (ComfyUI workflow JSON library).
"enable_workflows": False,
"recipes_path": "",
"sidecar_storage_mode": "alongside",
"sidecar_storage_path": "",
@@ -823,6 +825,40 @@ class SettingsManager:
"""Return True when the opt-in Other Models management is enabled."""
return bool(self.settings.get("enable_other_models", False))
def is_workflows_enabled(self) -> bool:
"""Return True when the opt-in Workflow management is enabled."""
return bool(self.settings.get("enable_workflows", False))
def get_workflow_folder_paths(self) -> List[str]:
"""Workflow roots configured for the active library (normalized)."""
folder_paths = self.settings.get("folder_paths", {})
if not isinstance(folder_paths, Mapping):
return []
normalized = self._normalize_folder_paths(
{"workflows": folder_paths.get("workflows", [])}
)
return normalized.get("workflows", [])
def set_workflow_folder_paths(self, paths: Any) -> List[str]:
"""Replace the active library's workflow roots and apply them live.
Unlike the core folder_paths keys (host-owned in plugin mode, applied
on restart in standalone), workflow roots are LoRA-Manager-only in
both modes, so they merge into the stored folder_paths map and take
effect immediately via ``_apply_workflow_settings_change``.
"""
normalized = self._normalize_folder_paths({"workflows": paths}).get(
"workflows", []
)
folder_paths = dict(self.settings.get("folder_paths") or {})
if normalized:
folder_paths["workflows"] = normalized
else:
folder_paths.pop("workflows", None)
self.set("folder_paths", folder_paths)
self._apply_workflow_settings_change()
return normalized
def get_enabled_other_sub_types(self) -> List[str]:
"""Return the enabled other-model sub_types (empty when the feature is off)."""
if not self.is_other_models_enabled():
@@ -855,6 +891,28 @@ class SettingsManager:
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
def _apply_workflow_settings_change(self) -> None:
"""Rebuild workflow roots and refresh the workflow scanner after a toggle."""
try:
from ..config import config # Local import to avoid circular dependency
config.refresh_workflow_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh workflow roots: %s", exc)
try:
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
scanner = ServiceRegistry.get_service_sync("workflow_scanner")
if scanner is not None and hasattr(scanner, "on_library_changed"):
# reconcile=True lets the scanner pick up newly enabled roots and
# purge rows for folders that are no longer managed.
scanner.on_library_changed(reconcile=True)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug(
"Failed to refresh workflow scanner after settings change: %s", exc
)
def _has_configured_paths(self, folder_paths: Any) -> bool:
if not isinstance(folder_paths, Mapping):
return False
@@ -2040,6 +2098,8 @@ class SettingsManager:
value = normalize_other_sub_types(value)
elif key == "enable_other_models":
value = bool(value)
elif key == "enable_workflows":
value = bool(value)
elif key == "recipes_path":
current_recipes_dir = self._get_effective_recipes_dir()
value = self._normalize_recipes_path_value(value)
@@ -2086,6 +2146,8 @@ class SettingsManager:
self._refresh_sidecar_storage_config()
if key in ("enable_other_models", "enabled_other_sub_types"):
self._apply_other_model_settings_change()
if key == "enable_workflows":
self._apply_workflow_settings_change()
if portable_switch_pending:
self._finalize_portable_switch()
+124
View File
@@ -0,0 +1,124 @@
"""In-memory registry of LM-linked workflows currently open in ComfyUI tabs.
The ComfyUI-side extension reports the active graph's provenance marker
(``extra.lm_source``: file path, document hash, dirty flag) whenever it
changes — load, tab switch, first edit, successful write-back. The LM web UI
consumes this to enable/disable the card context menu's "Update from
ComfyUI" entry, and to route the write-back request to the ComfyUI tab that
actually holds the workflow.
State is per ComfyUI client (browser tab, identified by its websocket sid)
and lives only in memory: a restarted server simply starts empty, and stale
entries are pruned against the live socket set on read.
"""
from __future__ import annotations
import asyncio
import logging
import time
import uuid
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class WorkflowLinkRegistry:
"""Per-client state of LM-managed workflows open in ComfyUI."""
_instance: Optional["WorkflowLinkRegistry"] = None
_instance_lock: Optional[asyncio.Lock] = None
def __init__(self) -> None:
# sid -> {"file_path", "sha256", "dirty", "name", "reported_at"}
self._states: Dict[str, Dict[str, Any]] = {}
# request_id -> Future resolved by the collect-result endpoint
self._collect_futures: Dict[str, asyncio.Future] = {}
self._lock = asyncio.Lock()
@classmethod
async def get_instance(cls) -> "WorkflowLinkRegistry":
if cls._instance is None:
if cls._instance_lock is None:
cls._instance_lock = asyncio.Lock()
async with cls._instance_lock:
if cls._instance is None:
cls._instance = WorkflowLinkRegistry()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Drop the singleton (test isolation)."""
cls._instance = None
async def report_state(
self, sid: str, state: Optional[Dict[str, Any]]
) -> None:
"""Store (or clear, when ``state`` is None) a client's link state."""
async with self._lock:
if state is None:
self._states.pop(sid, None)
return
self._states[sid] = {
"file_path": state.get("file_path"),
"sha256": state.get("sha256"),
"dirty": bool(state.get("dirty")),
"name": state.get("name") or "",
"reported_at": time.time(),
}
async def get_states(
self, active_sids: Optional[set] = None
) -> List[Dict[str, Any]]:
"""Return all known link states, pruning disconnected clients."""
async with self._lock:
if active_sids is not None:
for sid in list(self._states):
if sid not in active_sids:
del self._states[sid]
return [
{"client_id": sid, **state}
for sid, state in self._states.items()
]
async def find_client_for(
self, file_path: str, active_sids: Optional[set] = None
) -> Optional[str]:
"""Sid of a client holding *file_path*; dirty holders win."""
states = await self.get_states(active_sids)
fallback: Optional[str] = None
for state in states:
if state.get("file_path") != file_path:
continue
if state.get("dirty"):
return state["client_id"]
fallback = fallback or state["client_id"]
return fallback
def begin_collect(self) -> tuple[str, "asyncio.Future"]:
"""Create a pending collect request; the id rides the WS message."""
request_id = uuid.uuid4().hex
loop = asyncio.get_running_loop()
future = loop.create_future()
self._collect_futures[request_id] = future
return request_id, future
async def resolve_collect(
self, request_id: str, result: Dict[str, Any]
) -> bool:
"""Resolve a pending collect request. False when unknown/expired."""
future = self._collect_futures.pop(request_id, None)
if future is None or future.done():
return False
future.set_result(result)
return True
async def await_collect(
self, request_id: str, future: "asyncio.Future", timeout: float
) -> Dict[str, Any]:
"""Await a collect result; on timeout return a synthetic error."""
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._collect_futures.pop(request_id, None)
return {"success": False, "error": "timeout"}
+177
View File
@@ -0,0 +1,177 @@
"""Scanner for ComfyUI workflow resources (.json documents and containers)."""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import List, Optional
from ..config import config
from ..utils.file_utils import (
calculate_sha256,
find_preview_file,
normalize_path,
)
from ..utils.metadata_manager import MetadataManager
from ..utils.models import WorkflowMetadata
from ..utils.sidecar_paths import get_preview_dir
from ..utils.workflow_analysis import analyze_workflow_file
from ..utils.workflow_extraction import (
IMAGE_CONTAINER_EXTENSIONS,
WORKFLOW_ARTIFACT_EXTENSIONS,
WORKFLOW_DOCUMENT_SUFFIX,
materialize_workflow_document,
)
from .model_hash_index import ModelHashIndex
from .model_scanner import ModelScanner
logger = logging.getLogger(__name__)
# Sidecar files share the .json extension; they must never enter the library.
_SIDECAR_SUFFIXES = (".metadata.json", WORKFLOW_DOCUMENT_SUFFIX)
class WorkflowScanner(ModelScanner):
"""Scan and manage ComfyUI workflow artifacts.
Library entries are the original uploaded files, kept byte-identical so
their SHA256 stays the CivitAI identity (by-hash lookup and version
tracking depend on it). ``.json`` artifacts carry the graph directly;
``.zip``/``.png``/``.webp`` containers get the workflow document
extracted into a derived ``.workflow.json`` sidecar that every consumer
(graph analysis, send-to-ComfyUI) reads. Files from which no workflow
document can be extracted never enter the library — this keeps settings
JSON and plain preview images out.
"""
def __init__(self):
super().__init__(
model_type="workflow",
model_class=WorkflowMetadata,
file_extensions=set(WORKFLOW_ARTIFACT_EXTENSIONS),
hash_index=ModelHashIndex(),
)
def _is_indexable_filename(self, filename: str) -> bool:
lowered = filename.lower()
return not lowered.endswith(_SIDECAR_SUFFIXES)
def get_model_roots(self) -> List[str]:
"""Workflow roots (primary + extra), empty while the feature is off."""
try:
from .settings_manager import get_settings_manager
if not get_settings_manager().is_workflows_enabled():
return []
except Exception: # pragma: no cover - defensive; settings unavailable
return []
roots: List[str] = []
roots.extend(config.workflows_roots or [])
roots.extend(config.extra_workflows_roots or [])
seen: set[str] = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots
@staticmethod
def _is_image_artifact(file_path: str) -> bool:
return os.path.splitext(file_path)[1].lower() in IMAGE_CONTAINER_EXTENSIONS
def adjust_metadata(self, metadata, file_path, root_path):
"""Fill workflow analysis fields when they are missing.
Runs on every scan and on the post-download path. The extraction
result is cached in the derived ``.workflow.json`` sidecar, so the
steady-state cost is one small JSON parse per entry whose analysis
fields have not been populated yet.
"""
if not isinstance(metadata, WorkflowMetadata) or metadata.workflow_format:
return metadata
try:
document_path = materialize_workflow_document(file_path)
analysis = (
analyze_workflow_file(document_path) if document_path else None
)
except Exception as exc:
logger.debug(
"Workflow analysis refresh failed for %s: %s", file_path, exc
)
return metadata
if analysis is None:
return metadata
metadata.workflow_format = analysis.workflow_format
metadata.node_count = analysis.node_count
metadata.node_classes = analysis.node_classes
metadata.model_refs = analysis.model_refs
metadata.civitai_refs = analysis.civitai_refs
return metadata
async def _create_default_metadata(
self, file_path: str
) -> Optional[WorkflowMetadata]:
"""Create metadata by hashing the artifact and analyzing its workflow.
Returns None for files that carry no ComfyUI workflow document, so
they stay out of the library.
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
return None
document_path = materialize_workflow_document(
file_path, read_path=real_path
)
analysis = (
analyze_workflow_file(document_path) if document_path else None
)
if analysis is None:
logger.debug(f"Skipping artifact without a workflow: {file_path}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
if not preview_url and self._is_image_artifact(file_path):
# An image artifact doubles as its own preview.
preview_url = file_path
sha256 = await calculate_sha256(real_path)
if not sha256:
logger.warning(
f"Skipping metadata creation for empty workflow file: {file_path}"
)
return None
metadata = WorkflowMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
from_civitai=False,
workflow_format=analysis.workflow_format,
node_count=analysis.node_count,
node_classes=analysis.node_classes,
model_refs=analysis.model_refs,
civitai_refs=analysis.civitai_refs,
)
logger.info(f"Creating workflow metadata for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(
f"Error creating default workflow metadata for {file_path}: {e}"
)
return None
+84
View File
@@ -0,0 +1,84 @@
import os
import logging
from typing import Any, Dict, Optional
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import WorkflowMetadata
from ..config import config
logger = logging.getLogger(__name__)
class WorkflowService(BaseModelService):
"""Workflow-specific service implementation (ComfyUI workflow JSON)."""
def __init__(self, scanner, update_service=None):
"""Initialize Workflow service
Args:
scanner: Workflow scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__(
"workflow", scanner, WorkflowMetadata, update_service=update_service
)
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Format workflow data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
file_path = model_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted workflow entry (missing file_path): %s",
model_data.get("file_name", "<unknown>"),
)
return None
file_name = model_data.get("file_name") or ""
model_name = model_data.get("model_name") or file_name
folder = model_data.get("folder") or ""
return {
"model_name": model_name,
"file_name": file_name,
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
"base_model": model_data.get("base_model", ""),
"folder": folder,
"sha256": model_data.get("sha256", ""),
"file_path": file_path.replace(os.sep, "/"),
"file_size": model_data.get("size", 0),
"modified": model_data.get("modified", ""),
"tags": model_data.get("tags", []),
"from_civitai": model_data.get("from_civitai", True),
"notes": model_data.get("notes", ""),
"sub_type": "workflow",
"favorite": model_data.get("favorite", False),
"exclude": bool(model_data.get("exclude", False)),
"update_available": bool(model_data.get("update_available", False)),
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"workflow_format": model_data.get("workflow_format", ""),
"node_count": model_data.get("node_count", 0),
"node_classes": model_data.get("node_classes", []),
"model_refs": model_data.get("model_refs", []),
"civitai_refs": model_data.get("civitai_refs", []),
}
def find_duplicate_hashes(self) -> Dict[str, Any]:
"""Find workflows with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict[str, Any]:
"""Find workflows with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()
+6
View File
@@ -152,6 +152,9 @@ def folder_path_schema() -> List[Dict[str, Any]]:
{"key": key, "category": "core", "sub_type": None}
for key in CORE_FOLDER_PATH_KEYS
]
# Workflow roots are LoRA-Manager-only (no ComfyUI folder category); the
# standalone Model Paths UI gates this editor on ``enable_workflows``.
schema.append({"key": "workflows", "category": "workflow", "sub_type": None})
schema.extend(
{"key": folder_key, "category": "other", "sub_type": sub_type}
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
@@ -187,6 +190,9 @@ VALID_OTHER_CIVITAI_TYPES = {
"controlnet",
"other",
}
# CivitAI model.type values that map onto the workflow library (lowercased).
# ComfyWorkflows is retired on CivitAI but grandfathered models still carry it.
WORKFLOW_CIVITAI_TYPES = frozenset({"workflows", "comfyworkflows"})
# CivitAI model.type -> internal sub_type for the "other" model page.
CIVITAI_TYPE_TO_OTHER_SUB_TYPE = {
"vae": "vae",
+5 -1
View File
@@ -43,7 +43,11 @@ def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
if not directory_path:
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Business-path convention: normalize lexically with abspath but
# never resolve symlinks (no resolve()/realpath). The picker result
# is stored as a configured library path, and stored paths must
# match what the user sees on disk.
path = Path(os.path.abspath(os.path.expanduser(directory_path)))
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
+10
View File
@@ -161,6 +161,16 @@ class ExifUtils:
return value
return str(value)
@staticmethod
def load_structured_metadata(image_path: str) -> dict[str, Optional[str]]:
"""Public wrapper for the structured-metadata reader.
Returns a dict with ``parameters``/``prompt``/``workflow``/``comment``
keys (values may be None). May raise on unreadable images; callers
that treat metadata as optional should catch exceptions.
"""
return ExifUtils._load_structured_metadata(image_path)
@staticmethod
def _load_structured_metadata(image_path: str) -> dict[str, Optional[str]]:
metadata: dict[str, Optional[str]] = {
+55
View File
@@ -488,3 +488,58 @@ class EmbeddingMetadata(BaseModelMetadata):
# Direct read: the downloaded file IS file_info, no SHA256 matching.
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
)
@dataclass
class WorkflowMetadata(BaseModelMetadata):
"""Represents the metadata structure for a ComfyUI workflow resource.
Workflows are JSON graph documents (CivitAI model type ``Workflows`` /
retired ``ComfyWorkflows``), managed like any other model: dedicated roots,
sidecar metadata, previews, and version tracking. The workflow-specific
fields are populated by ``py.utils.workflow_analysis`` during scanning.
"""
sub_type: str = "workflow"
workflow_format: str = "" # "ui" | "api"; empty until analyzed
node_count: int = 0
node_classes: List[str] = field(default_factory=list)
model_refs: List[str] = field(default_factory=list)
civitai_refs: List[str] = field(default_factory=list)
@classmethod
def from_civitai_info(
cls, version_info: Dict[str, Any], file_info: Dict[str, Any], save_path: str
) -> "WorkflowMetadata":
"""Create WorkflowMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
# Workflow artifacts are .json/.zip/.png/... — none of them weight
# extensions — so strip one real extension instead of consulting the
# weight-extension allowlist.
base_name = os.path.splitext(file_name)[0]
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
tags = []
description = ""
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=sha256_value,
base_model=base_model,
preview_url="", # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
tags=tags,
modelDescription=description,
)
+2
View File
@@ -205,10 +205,12 @@ def _iter_model_roots() -> List[str]:
"base_models_roots",
"embeddings_roots",
"other_roots",
"workflows_roots",
"extra_loras_roots",
"extra_checkpoints_roots",
"extra_unet_roots",
"extra_embeddings_roots",
"extra_workflows_roots",
):
value = getattr(config, attr, None)
if value:
+189
View File
@@ -0,0 +1,189 @@
"""ComfyUI workflow JSON sniffing and analysis.
Shared by the workflow scanner (library indexing) and, eventually, the recipe
parsers: both need to recognize ComfyUI workflow documents and extract the
models and node classes they reference.
Two on-disk shapes exist:
- UI format (``File > Save`` in ComfyUI): ``{"nodes": [...], "links": [...], ...}``
- API format (queue prompt payload): ``{"<node_id>": {"class_type": ..., "inputs": {...}}}``
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set
from .constants import MODEL_FILE_EXTENSIONS
logger = logging.getLogger(__name__)
WORKFLOW_FORMAT_UI = "ui"
WORKFLOW_FORMAT_API = "api"
# Workflows are small JSON documents; anything larger is almost certainly not
# a graph (or is pathological) and is not worth parsing during a scan.
MAX_WORKFLOW_JSON_BYTES = 64 * 1024 * 1024
_CIVITAI_URN_PATTERN = re.compile(r"civitai:(\d+)@(\d+)")
# Loader input keys whose string values name a model file. Used for the API
# format; the UI format is handled by the generic extension scan below.
_LOADER_INPUT_KEYS = frozenset(
{
"lora_name",
"ckpt_name",
"unet_name",
"vae_name",
"clip_name",
"control_net_name",
"embedding_name",
"model_name",
}
)
@dataclass
class WorkflowAnalysis:
"""Structured facts extracted from a ComfyUI workflow document."""
workflow_format: str
node_count: int = 0
node_classes: List[str] = field(default_factory=list)
model_refs: List[str] = field(default_factory=list)
civitai_refs: List[str] = field(default_factory=list)
def _sniff(data: Any) -> Optional[str]:
"""Return the workflow format for a parsed JSON document, or None."""
if not isinstance(data, dict):
return None
nodes = data.get("nodes")
links = data.get("links")
if isinstance(nodes, list) and nodes and isinstance(links, list):
return WORKFLOW_FORMAT_UI
# API format: a non-empty mapping of node id -> {class_type, inputs}.
if data and all(
isinstance(value, dict) and "class_type" in value for value in data.values()
):
return WORKFLOW_FORMAT_API
return None
def sniff_workflow_text(text: str) -> Optional[str]:
"""Sniff raw JSON text; returns the workflow format or None."""
try:
data = json.loads(text)
except (TypeError, ValueError):
return None
return _sniff(data)
def parse_workflow_text(text: str) -> Optional[Dict[str, Any]]:
"""Parse + sniff raw JSON text; return the document when it is a workflow."""
try:
data = json.loads(text)
except (TypeError, ValueError):
return None
if _sniff(data) is None:
return None
return data
def load_workflow_file(file_path: str) -> Optional[Dict[str, Any]]:
"""Load and sniff a workflow JSON file.
Returns the parsed document when it is a recognized ComfyUI workflow,
None otherwise (unreadable, oversized, invalid JSON, or not a workflow).
"""
try:
with open(file_path, "r", encoding="utf-8") as handle:
text = handle.read(MAX_WORKFLOW_JSON_BYTES + 1)
except (OSError, UnicodeDecodeError) as exc:
logger.debug("Workflow file unreadable: %s (%s)", file_path, exc)
return None
if len(text) > MAX_WORKFLOW_JSON_BYTES:
return None
try:
data = json.loads(text)
except ValueError:
return None
if _sniff(data) is None:
return None
return data
def _walk_strings(value: Any, out: List[str], depth: int = 0) -> None:
"""Collect every string scalar in a JSON structure (bounded depth)."""
if depth > 12 or len(out) > 10000:
return
if isinstance(value, str):
out.append(value)
elif isinstance(value, dict):
for item in value.values():
_walk_strings(item, out, depth + 1)
elif isinstance(value, (list, tuple)):
for item in value:
_walk_strings(item, out, depth + 1)
def analyze_workflow(data: Dict[str, Any]) -> Optional[WorkflowAnalysis]:
"""Analyze a parsed workflow document. None when not a workflow."""
workflow_format = _sniff(data)
if workflow_format is None:
return None
node_classes: Set[str] = set()
node_count = 0
if workflow_format == WORKFLOW_FORMAT_API:
node_count = len(data)
for node in data.values():
class_type = node.get("class_type")
if isinstance(class_type, str) and class_type:
node_classes.add(class_type)
else:
nodes = data.get("nodes") or []
node_count = len(nodes)
for node in nodes:
if not isinstance(node, dict):
continue
class_type = node.get("type")
if isinstance(class_type, str) and class_type:
node_classes.add(class_type)
strings: List[str] = []
_walk_strings(data, strings)
model_refs: Set[str] = set()
civitai_refs: Set[str] = set()
for value in strings:
for match in _CIVITAI_URN_PATTERN.finditer(value):
civitai_refs.add(f"civitai:{match.group(1)}@{match.group(2)}")
lowered = value.lower()
if any(lowered.endswith(ext) for ext in MODEL_FILE_EXTENSIONS):
# Widget values are single model filenames; skip strings that
# clearly carry more (URLs, prompts with punctuation-heavy tails
# still end in an extension, so also bound the length).
if len(value) <= 260 and " " not in value.strip():
model_refs.add(value.strip())
return WorkflowAnalysis(
workflow_format=workflow_format,
node_count=node_count,
node_classes=sorted(node_classes),
model_refs=sorted(model_refs),
civitai_refs=sorted(civitai_refs),
)
def analyze_workflow_file(file_path: str) -> Optional[WorkflowAnalysis]:
"""Load + analyze a workflow file; None when it is not a workflow."""
data = load_workflow_file(file_path)
if data is None:
return None
return analyze_workflow(data)
+202
View File
@@ -0,0 +1,202 @@
"""Extraction of the ComfyUI workflow document from a library artifact.
The workflow library keeps the original uploaded file byte-identical — its
SHA256 is the identity CivitAI hashed (by-hash lookup and version tracking
depend on it). Consumers (send-to-ComfyUI, graph analysis) work on the
extracted workflow document instead:
- ``.json`` artifacts ARE the document; they are validated and read in place.
- ``.zip`` / ``.png`` / ``.webp`` containers get the document materialized
once into an LM-owned derived sidecar ``<name>.workflow.json`` inside the
artifact's sidecar directory. Scanners exclude the suffix from the library
walk; lifecycle operations (move/rename/delete) carry it like any other
sidecar.
"""
from __future__ import annotations
import json
import logging
import os
import zipfile
from typing import Any, Dict, List, Optional
from .sidecar_paths import get_sidecar_dir
from .workflow_analysis import (
MAX_WORKFLOW_JSON_BYTES,
load_workflow_file,
parse_workflow_text,
)
logger = logging.getLogger(__name__)
WORKFLOW_DOCUMENT_SUFFIX = ".workflow.json"
# Image containers whose metadata chunks can embed a ComfyUI workflow.
IMAGE_CONTAINER_EXTENSIONS = frozenset({".png", ".webp"})
# Every file type the workflow library accepts.
WORKFLOW_ARTIFACT_EXTENSIONS = frozenset({".json", ".zip"} | IMAGE_CONTAINER_EXTENSIONS)
# Bounds for zip exploration: workflow archives are user uploads with no
# structural contract, so candidate discovery is capped rather than exhaustive.
_MAX_ZIP_JSON_CANDIDATES = 64
def get_workflow_document_path(artifact_path: str) -> str:
"""Return the path of the extracted workflow document for an artifact.
``.json`` artifacts are their own document; containers map to the
derived ``<name>.workflow.json`` in the artifact's sidecar directory
(alongside the artifact or in the centralized mirror).
"""
if os.path.splitext(artifact_path)[1].lower() == ".json":
return artifact_path
base_name = os.path.splitext(os.path.basename(artifact_path))[0]
return os.path.join(
get_sidecar_dir(artifact_path), base_name + WORKFLOW_DOCUMENT_SUFFIX
)
def _zip_json_candidates(archive: zipfile.ZipFile) -> List[zipfile.ZipInfo]:
"""Return the archive's plausible workflow documents, best first."""
candidates: List[zipfile.ZipInfo] = []
for info in archive.infolist():
if info.is_dir():
continue
parts = info.filename.replace("\\", "/").split("/")
# Skip macOS resource forks and hidden files/directories.
if any(part == "__MACOSX" or part.startswith(".") for part in parts):
continue
if not parts[-1].lower().endswith(".json"):
continue
if info.file_size > MAX_WORKFLOW_JSON_BYTES:
continue
candidates.append(info)
if len(candidates) >= _MAX_ZIP_JSON_CANDIDATES:
break
# Prefer shallow, small documents: the workflow is typically the
# archive's headline content, not a bundled config deep in a subfolder.
candidates.sort(key=lambda info: (len(info.filename.split("/")), info.file_size))
return candidates
def _extract_from_zip(archive_path: str) -> Optional[Dict[str, Any]]:
try:
with zipfile.ZipFile(archive_path, "r") as archive:
for info in _zip_json_candidates(archive):
try:
with archive.open(info) as handle:
raw = handle.read(MAX_WORKFLOW_JSON_BYTES + 1)
text = raw.decode("utf-8")
except (OSError, UnicodeDecodeError, zipfile.BadZipFile):
continue
document = parse_workflow_text(text)
if document is not None:
return document
except (OSError, zipfile.BadZipFile) as exc:
logger.debug("Workflow archive unreadable: %s (%s)", archive_path, exc)
return None
def _extract_from_image(image_path: str) -> Optional[Dict[str, Any]]:
# Local import: PIL/piexif are heavy and only needed for image artifacts.
from .exif_utils import ExifUtils
try:
metadata = ExifUtils.load_structured_metadata(image_path)
except Exception as exc:
logger.debug("Workflow image metadata unreadable: %s (%s)", image_path, exc)
return None
# The UI-format document ("workflow") is what ComfyUI's loadGraphData
# consumes; fall back to the API-format "prompt" payload.
for key in ("workflow", "prompt"):
text = metadata.get(key)
if not text:
continue
document = parse_workflow_text(text)
if document is not None:
return document
return None
def extract_workflow_document(artifact_path: str) -> Optional[Dict[str, Any]]:
"""Extract the ComfyUI workflow document from an artifact.
Returns the parsed document when the artifact carries a recognizable
workflow, ``None`` otherwise (unreadable file, unknown extension, no
workflow inside). Never raises.
"""
extension = os.path.splitext(artifact_path)[1].lower()
try:
if extension == ".json":
return load_workflow_file(artifact_path)
if extension == ".zip":
return _extract_from_zip(artifact_path)
if extension in IMAGE_CONTAINER_EXTENSIONS:
return _extract_from_image(artifact_path)
except Exception as exc: # defensive: extraction must never break a scan
logger.debug("Workflow extraction failed for %s: %s", artifact_path, exc)
return None
def materialize_workflow_document(
artifact_path: str, *, read_path: Optional[str] = None
) -> Optional[str]:
"""Ensure the extracted workflow document exists on disk.
Returns the document path, or ``None`` when the artifact carries no
recognizable workflow. ``.json`` artifacts are their own document (the
path is returned after validation; nothing is written). Containers get
the document written to the derived sidecar path; an up-to-date derived
file is reused without re-extracting.
``read_path`` overrides the path the artifact bytes are read from (the
scanner hashes/extracts via the real path while sidecar locations derive
from the business path).
"""
read_path = read_path or artifact_path
if os.path.splitext(artifact_path)[1].lower() == ".json":
return artifact_path if load_workflow_file(read_path) is not None else None
document_path = get_workflow_document_path(artifact_path)
try:
if os.path.exists(document_path) and os.path.getmtime(
document_path
) >= os.path.getmtime(read_path):
return document_path
except OSError:
pass
document = extract_workflow_document(read_path)
if document is None:
return None
try:
os.makedirs(os.path.dirname(document_path), exist_ok=True)
with open(document_path, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False)
except OSError as exc:
logger.warning(
"Failed to write workflow document %s: %s", document_path, exc
)
return None
return document_path
def load_artifact_workflow_document(artifact_path: str) -> Optional[Dict[str, Any]]:
"""Load the workflow document for a library artifact.
Reads the derived sidecar for containers (materializing it on demand
when missing or stale) and the artifact itself for bare JSON.
"""
document_path = materialize_workflow_document(artifact_path)
if document_path is None:
return None
return load_workflow_file(document_path)
+20 -1
View File
@@ -10,7 +10,8 @@ export const MODEL_TYPES = {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
OTHER: 'other'
OTHER: 'other',
WORKFLOW: 'workflows'
};
// Base API configuration for each model type
@@ -54,6 +55,16 @@ export const MODEL_CONFIG = {
supportsMove: true,
supportsFolderManagement: true,
templateName: 'other.html'
},
[MODEL_TYPES.WORKFLOW]: {
displayName: 'Workflow',
singularName: 'workflow',
defaultPageSize: 100,
supportsLetterFilter: false,
supportsBulkOperations: true,
supportsMove: true,
supportsFolderManagement: true,
templateName: 'workflows.html'
}
};
@@ -168,6 +179,14 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
[MODEL_TYPES.OTHER]: {
metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`,
roots_by_subtype: `/api/lm/${MODEL_TYPES.OTHER}/roots_by_subtype`,
},
[MODEL_TYPES.WORKFLOW]: {
metadata: `/api/lm/${MODEL_TYPES.WORKFLOW}/metadata`,
sendToComfy: `/api/lm/${MODEL_TYPES.WORKFLOW}/send-to-comfy`,
content: `/api/lm/${MODEL_TYPES.WORKFLOW}/content`,
comfyuiState: `/api/lm/${MODEL_TYPES.WORKFLOW}/comfyui-state`,
updateFromComfy: `/api/lm/${MODEL_TYPES.WORKFLOW}/update-from-comfy`,
restore: `/api/lm/${MODEL_TYPES.WORKFLOW}/restore`,
}
};
+3
View File
@@ -2,6 +2,7 @@ import { LoraApiClient } from './loraApi.js';
import { CheckpointApiClient } from './checkpointApi.js';
import { EmbeddingApiClient } from './embeddingApi.js';
import { OtherApiClient } from './otherApi.js';
import { WorkflowApiClient } from './workflowApi.js';
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
import { state } from '../state/index.js';
@@ -15,6 +16,8 @@ export function createModelApiClient(modelType) {
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
case MODEL_TYPES.OTHER:
return new OtherApiClient(MODEL_TYPES.OTHER);
case MODEL_TYPES.WORKFLOW:
return new WorkflowApiClient(MODEL_TYPES.WORKFLOW);
default:
throw new Error(`Unsupported model type: ${modelType}`);
}
+93
View File
@@ -0,0 +1,93 @@
import { BaseModelApiClient } from './baseModelApi.js';
/**
* Workflow-specific API client (ComfyUI workflow JSON resources)
*/
export class WorkflowApiClient extends BaseModelApiClient {
/**
* Push a library workflow into the running ComfyUI graph.
* POST /api/lm/workflows/send-to-comfy { file_path }
*/
async sendToComfy(filePath) {
const response = await fetch(this.apiConfig.endpoints.specific.sendToComfy, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data;
}
/**
* Fetch the extracted workflow document for a library entry.
* GET /api/lm/workflows/content?file_path=... (works in standalone mode).
* @returns {Promise<object>} The workflow JSON document.
*/
async fetchWorkflowContent(filePath) {
const url = `${this.apiConfig.endpoints.specific.content}?file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(url);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data.workflow;
}
/**
* List LM-managed workflows currently open in live ComfyUI tabs.
* GET /api/lm/workflows/comfyui-state
* @returns {Promise<Array>} [{client_id, file_path, sha256, dirty}, ...]
*/
async fetchComfyuiState() {
const response = await fetch(this.apiConfig.endpoints.specific.comfyuiState);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data.states || [];
}
/**
* Ask the ComfyUI tab holding this workflow to write its graph back.
* POST /api/lm/workflows/update-from-comfy { file_path }
* The request resolves with the write-back outcome; `error` carries the
* failure kind ('not_open', 'no_changes', 'cancelled', 'timeout', ...).
*/
async updateFromComfy(filePath) {
const response = await fetch(this.apiConfig.endpoints.specific.updateFromComfy, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const error = new Error(data.message || data.error || `HTTP ${response.status}`);
error.kind = data.error || 'unknown';
throw error;
}
return data;
}
/**
* Restore a workflow to its downloaded state, discarding local edits.
* POST /api/lm/workflows/restore { file_path }
* `error` carries the failure kind ('no_source', 'download_failed', ...).
*/
async restore(filePath) {
const response = await fetch(this.apiConfig.endpoints.specific.restore, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const error = new Error(data.message || data.error || `HTTP ${response.status}`);
error.kind = data.error || 'unknown';
throw error;
}
return data;
}
}
@@ -115,7 +115,8 @@ export const ModelContextMenuMixin = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings',
other: 'other'
other: 'other',
workflow: 'workflows'
};
return prefixMap[this.modelType] || 'loras';
},
@@ -0,0 +1,148 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { moveManager } from '../../managers/MoveManager.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { copyWorkflowToClipboard, restoreWorkflow, sendWorkflowToComfy, updateWorkflowFromComfy } from '../../utils/workflowActions.js';
import { state } from '../../state/index.js';
export class WorkflowContextMenu extends BaseContextMenu {
constructor() {
super('workflowContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'workflows';
this.resetAndReload = resetAndReload;
this.initNSFWSelector();
}
// Implementation needed by the mixin
async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data);
}
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
// Standalone has no running ComfyUI to push a workflow into; the
// copy-to-clipboard action covers that mode instead.
const sendItem = this.menu?.querySelector('[data-action="send-to-comfy"]');
const updateItem = this.menu?.querySelector('[data-action="update-from-comfy"]');
const standalone = state?.global?.settings?.standalone_mode;
if (sendItem) {
sendItem.style.display = standalone ? 'none' : '';
}
if (updateItem) {
if (standalone) {
updateItem.style.display = 'none';
} else {
updateItem.style.display = '';
this._refreshUpdateFromComfyItem(card, updateItem);
}
}
const restoreItem = this.menu?.querySelector('[data-action="restore-download"]');
if (restoreItem) {
restoreItem.classList.toggle('disabled', !this._isRestorable(card));
}
}
/**
* Restore is available for container artifacts (re-extracted locally from
* the pristine archive) and for CivitAI-sourced .json artifacts
* (re-downloaded from the recorded version). Local-only .json files have
* no restore source; when the item is not in the loaded window we stay
* enabled and let the backend report "no_source".
*/
_isRestorable(card) {
const filePath = card?.dataset?.filepath;
if (!filePath) return false;
if (!filePath.toLowerCase().endsWith('.json')) return true;
const item = state.virtualScroller?.items?.find((entry) => entry.file_path === filePath);
return item ? !!item.from_civitai : true;
}
/**
* Enable "Update from ComfyUI" only when this workflow is the active
* graph in a connected ComfyUI tab and carries unsaved edits. The state
* is pushed by the ComfyUI-side extension; the item starts disabled and
* flips when the answer arrives while the menu is still open.
*/
async _refreshUpdateFromComfyItem(card, item) {
item.classList.add('disabled');
const filePath = card?.dataset?.filepath;
if (!filePath) return;
try {
const states = await getModelApiClient().fetchComfyuiState();
// Menu may have been closed/re-opened for another card meanwhile.
if (this.currentCard !== card) return;
const linked = states.some(
(entry) => entry.file_path === filePath && entry.dirty
);
item.classList.toggle('disabled', !linked);
} catch (_error) {
// State endpoint unavailable (older backend): keep disabled.
}
}
handleMenuAction(action) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
const apiClient = getModelApiClient();
// Otherwise handle workflow-specific actions
switch(action) {
case 'details':
// Show workflow details
this.currentCard.click();
break;
case 'send-to-comfy':
sendWorkflowToComfy(this.currentCard.dataset.filepath);
break;
case 'update-from-comfy':
updateWorkflowFromComfy(this.currentCard.dataset.filepath);
break;
case 'copy-workflow':
copyWorkflowToClipboard(this.currentCard.dataset.filepath);
break;
case 'restore-download':
restoreWorkflow(this.currentCard.dataset.filepath);
break;
case 'replace-preview':
// Add new action for replacing preview images
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
break;
case 'delete':
showDeleteModal(this.currentCard.dataset.filepath);
break;
case 'copyname':
// Copy workflow name
if (this.currentCard.querySelector('.fa-copy')) {
this.currentCard.querySelector('.fa-copy').click();
}
break;
case 'refresh-metadata':
// Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
}
}
async sendToComfy(filePath) {
await sendWorkflowToComfy(filePath);
}
}
// Mix in shared methods
Object.assign(WorkflowContextMenu.prototype, ModelContextMenuMixin);
@@ -3,6 +3,7 @@ export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
export { OtherContextMenu } from './OtherContextMenu.js';
export { WorkflowContextMenu } from './WorkflowContextMenu.js';
export { GlobalContextMenu } from './GlobalContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
@@ -11,6 +12,7 @@ import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
import { OtherContextMenu } from './OtherContextMenu.js';
import { WorkflowContextMenu } from './WorkflowContextMenu.js';
import { GlobalContextMenu } from './GlobalContextMenu.js';
// Factory method to create page-specific context menu instances
@@ -26,6 +28,8 @@ export function createPageContextMenu(pageType) {
return new EmbeddingContextMenu();
case 'other':
return new OtherContextMenu();
case 'workflows':
return new WorkflowContextMenu();
default:
return null;
}
+1
View File
@@ -33,6 +33,7 @@ export class HeaderManager {
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/other')) return 'other';
if (path.includes('/workflows')) return 'workflows';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
+1 -1
View File
@@ -3563,7 +3563,7 @@ export class SidebarManager {
_migrateOldSettings() {
if (getStorageItem('_sidebar_migration_done')) return;
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other', 'workflows'];
// 1. Migrate global hide setting to per-page
if (state?.global?.settings?.show_folder_sidebar === false) {
@@ -0,0 +1,62 @@
// WorkflowControls.js - Specific implementation for the Workflows page
import { PageControls } from './PageControls.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { showToast } from '../../utils/uiHelpers.js';
import { downloadManager } from '../../managers/DownloadManager.js';
/**
* WorkflowControls class - Extends PageControls for the Workflows page
* (ComfyUI workflow JSON resources)
*/
export class WorkflowControls extends PageControls {
constructor() {
// Initialize with 'workflows' page type
super('workflows');
// Register API methods specific to the Workflows page
this.registerWorkflowAPI();
}
/**
* Register workflow-specific API methods
*/
registerWorkflowAPI() {
const workflowAPI = {
// Core API functions
loadMoreModels: async (resetPage = false, updateFolders = false) => {
return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders);
},
resetAndReload: async (updateFolders = false) => {
return await resetAndReload(updateFolders);
},
// Add fetch from Civitai functionality for workflows
fetchFromCivitai: async () => {
return await getModelApiClient().fetchCivitaiMetadata();
},
// Add show download modal functionality
showDownloadModal: () => {
downloadManager.showDownloadModal();
},
toggleBulkMode: () => {
if (window.bulkManager) {
window.bulkManager.toggleBulkMode();
} else {
console.error('Bulk manager not available');
}
},
// No clearCustomFilter implementation is needed for workflows
// as custom filters are currently only used for LoRAs
clearCustomFilter: async () => {
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
}
};
// Register the API
this.registerAPI(workflowAPI);
}
}
+5 -2
View File
@@ -4,13 +4,14 @@ import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js';
import { EmbeddingsControls } from './EmbeddingsControls.js';
import { OtherControls } from './OtherControls.js';
import { WorkflowControls } from './WorkflowControls.js';
// Export the classes
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls };
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls, WorkflowControls };
/**
* Factory function to create the appropriate controls based on page type
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'other')
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', 'other', or 'workflows')
* @returns {PageControls} - The appropriate controls instance
*/
export function createPageControls(pageType) {
@@ -22,6 +23,8 @@ export function createPageControls(pageType) {
return new EmbeddingsControls();
} else if (pageType === 'other') {
return new OtherControls();
} else if (pageType === 'workflows') {
return new WorkflowControls();
} else {
console.error(`Unknown page type: ${pageType}`);
return null;
+1 -1
View File
@@ -10,7 +10,7 @@ import { setStorageItem } from '../utils/storageHelpers.js';
import { state } from '../state/index.js';
import { escapeHtml } from './shared/utils.js';
const LIBRARY_ORDER = ['loras', 'checkpoints', 'embeddings', 'other'];
const LIBRARY_ORDER = ['loras', 'checkpoints', 'embeddings', 'other', 'workflows'];
class FirstRunManager {
constructor() {
+3
View File
@@ -79,6 +79,8 @@ class InitializationManager {
this.pageType = 'checkpoints';
} else if (path.includes('/other')) {
this.pageType = 'other';
} else if (path.includes('/workflows')) {
this.pageType = 'workflows';
} else if (path.includes('/loras')) {
this.pageType = 'loras';
} else if (path.includes('/embeddings')) {
@@ -268,6 +270,7 @@ class InitializationManager {
'checkpoint': 'checkpoints',
'embedding': 'embeddings',
'other': 'other',
'workflow': 'workflows',
'recipe': 'recipes'
};
+16 -4
View File
@@ -9,6 +9,7 @@ import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatur
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { copyWorkflowToClipboard, sendWorkflowToComfy } from '../../utils/workflowActions.js';
import { translate } from '../../utils/i18nHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
@@ -239,6 +240,9 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
const name = card.dataset.file_name || '';
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
sendEmbeddingToWorkflow(embeddingCode);
} else if (modelType === MODEL_TYPES.WORKFLOW) {
// Workflow send loads the whole graph document; no append/replace modes.
sendWorkflowToComfy(card.dataset.filepath);
} else {
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
}
@@ -258,6 +262,8 @@ function handleCopyAction(card, modelType) {
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
copyToClipboard(embeddingCode, message);
} else if (modelType === MODEL_TYPES.WORKFLOW) {
copyWorkflowToClipboard(card.dataset.filepath);
} else {
// Other model types (VAE, upscalers, ...) - copy the file name
const fileName = card.dataset.file_name;
@@ -640,6 +646,9 @@ export function createModelCard(model, modelType) {
// Embedding send always appends to the prompt; no replace mode.
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
} else if (modelType === MODEL_TYPES.WORKFLOW) {
sendTitle = translate('modelCard.actions.openInComfyUI', {}, 'Open in ComfyUI');
copyTitle = translate('modelCard.actions.copyWorkflowToClipboard', {}, 'Copy workflow to clipboard');
} else {
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy value');
@@ -647,6 +656,12 @@ export function createModelCard(model, modelType) {
const updateBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
// Standalone has no running ComfyUI graph to push a workflow into.
const hideSendIcon = modelType === MODEL_TYPES.WORKFLOW && state.global.settings.standalone_mode;
const sendIcon = hideSendIcon ? '' : `
<i class="fas fa-paper-plane"
title="${sendTitle}">
</i>`;
const actionIcons = `
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${favoriteTitle}">
@@ -654,10 +669,7 @@ export function createModelCard(model, modelType) {
<i class="fas fa-globe"
title="${globeTitle}"
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="${sendTitle}">
</i>
</i>${sendIcon}
<i class="fas fa-copy"
title="${copyTitle}">
</i>`;
+22 -6
View File
@@ -22,6 +22,7 @@ import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForModel } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { sendWorkflowToComfy } from '../../utils/workflowActions.js';
import { state } from '../../state/index.js';
function getModalFilePath(fallback = '') {
@@ -499,16 +500,28 @@ export async function showModelModal(model, modelType) {
].join('\n')
: '';
const headerActionItems = [];
// Add send to ComfyUI button for all model types
const sendToWorkflowTitle = translate('modals.model.actions.sendToWorkflow', {}, 'Send to ComfyUI');
const sendToWorkflowButton = `
// Add send to ComfyUI button for all model types. Workflows hide it in
// standalone mode: there is no running ComfyUI graph to push into.
const hideSendButton = modelType === MODEL_TYPES.WORKFLOW && state.global.settings.standalone_mode;
if (!hideSendButton) {
// Workflows load a whole graph document, so the action reads "Open";
// model types that insert into the current graph keep "Send".
const isWorkflow = modelType === MODEL_TYPES.WORKFLOW;
const sendToWorkflowTitle = isWorkflow
? translate('workflows.contextMenu.sendToComfy', {}, 'Open in ComfyUI')
: translate('modals.model.actions.sendToWorkflow', {}, 'Send to ComfyUI');
const sendToWorkflowText = isWorkflow
? translate('workflows.contextMenu.sendToComfy', {}, 'Open in ComfyUI')
: translate('modals.model.actions.sendToWorkflowText', {}, 'Send to ComfyUI');
const sendToWorkflowButton = `
<button class="modal-send-btn" data-action="send-to-workflow" data-model-type="${modelType}" title="${sendToWorkflowTitle}">
<i class="fas fa-paper-plane"></i>
<span>${translate('modals.model.actions.sendToWorkflowText', {}, 'Send to ComfyUI')}</span>
<span>${sendToWorkflowText}</span>
</button>
`.trim();
headerActionItems.push(indentMarkup(sendToWorkflowButton, 20));
headerActionItems.push(indentMarkup(sendToWorkflowButton, 20));
}
if (creatorActionsMarkup) {
headerActionItems.push(creatorActionsMarkup);
@@ -1524,6 +1537,9 @@ async function handleSendToWorkflow(target, modelType) {
const name = currentFileName.replace(/\.[^.]+$/, '');
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
await sendEmbeddingToWorkflow(embeddingCode);
} else if (modelType === MODEL_TYPES.WORKFLOW) {
// Workflow send loads the whole graph document into ComfyUI.
await sendWorkflowToComfy(filePath);
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other', 'workflows'].includes(pageType)) {
this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType);
}
+3 -1
View File
@@ -541,6 +541,7 @@ class BannerService {
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/other')) return 'other';
if (path.includes('/workflows')) return 'workflows';
if (path.includes('/recipes')) return 'recipes';
return 'loras';
}
@@ -555,7 +556,8 @@ class BannerService {
'loras': '/api/lm/loras/reload?rebuild=true',
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
'embeddings': '/api/lm/embeddings/reload?rebuild=true',
'other': '/api/lm/other/reload?rebuild=true'
'other': '/api/lm/other/reload?rebuild=true',
'workflows': '/api/lm/workflows/reload?rebuild=true'
};
return endpoints[pageType] || endpoints['loras'];
}
+38 -7
View File
@@ -1477,6 +1477,7 @@ export class DownloadManager {
suppressSuccessToast = false,
suppressFailureSummary = false,
isLatestVersion = null,
wasLocallyOwned = null,
}) {
const config = this.apiClient?.apiConfig?.config;
@@ -1485,7 +1486,7 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary, isLatestVersion };
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary, isLatestVersion, wasLocallyOwned };
this._lastDownloadError = null;
let ws = null;
let updateProgress = () => { };
@@ -1640,6 +1641,7 @@ export class DownloadManager {
await this._reconcileViewAfterDownload({
modelId,
isLatestVersion: isLatestVersion ?? this._isDownloadingLatestVersion(versionId),
wasLocallyOwned,
});
}
@@ -1696,9 +1698,10 @@ export class DownloadManager {
* (the update flag is model-level, so every visible card of the
* model disappears at once).
* - Normal listing: the card stays; only the update flag is cleared.
* - The model is not in the current view (different folder / filter /
* window): nothing changes, which also covers brand-new models whose
* card did not exist before.
* - An owned model outside the current view (different folder / filter /
* window): nothing changes.
* - A brand-new model has no card to reconcile in place, so the listing
* falls back to the legacy reload to make the new card visible.
*
* The sidebar folder tree is refreshed separately so folder counts
* stay accurate without touching the model listing or scroll position.
@@ -1712,9 +1715,14 @@ export class DownloadManager {
* @param {boolean} [opts.refreshSidebar=true] Whether to refresh the
* sidebar folder tree afterwards (batch callers batch this into a
* single refresh).
* @param {boolean|null} [opts.wasLocallyOwned=null] Whether any version
* of the model was already in the library before this download. Only
* an explicit true keeps the no-op for a model outside the current
* view; false (brand-new) and null (unknown) reload the listing so a
* new card can appear.
* @returns {Promise<boolean>} True when an in-place update was applied.
*/
async _reconcileViewAfterDownload({ modelId, isLatestVersion = true, refreshSidebar = true } = {}) {
async _reconcileViewAfterDownload({ modelId, isLatestVersion = true, refreshSidebar = true, wasLocallyOwned = null } = {}) {
const scroller = state?.virtualScroller;
const items = Array.isArray(scroller?.items) ? scroller.items : [];
@@ -1738,8 +1746,15 @@ export class DownloadManager {
});
if (matches.length === 0) {
// Downloaded model is not visible in the current view — keep the
// listing untouched, only refresh folder counts.
// No card of this model is visible. A brand-new model (or one
// whose ownership is unknown) has no card anywhere in the
// listing to reconcile in place — fall back to the legacy
// reload so the new card becomes visible. An owned model that
// merely sits outside the current view keeps the no-op (#1078).
if (wasLocallyOwned !== true) {
await resetAndReload(true);
return true;
}
await this._refreshSidebarAfterReconcile(refreshSidebar);
return false;
}
@@ -1792,6 +1807,7 @@ export class DownloadManager {
modelId,
isLatestVersion: this._isVersionLatest(item.selectedVersion?.id, item.versions),
refreshSidebar: false,
wasLocallyOwned: this._computeWasLocallyOwned(item.versions),
});
}
await this._refreshSidebarAfterReconcile(true);
@@ -1851,6 +1867,19 @@ export class DownloadManager {
return this._isVersionLatest(versionId, this.versions);
}
/**
* Whether any version of the model was already in the library before
* the download, derived from the `existsLocally` flags of the version
* list the modal fetched up front. Returns null when no version list is
* available (callers outside the modal flow).
*/
_computeWasLocallyOwned(versions) {
if (!Array.isArray(versions) || versions.length === 0) {
return null;
}
return versions.some(version => version && version.existsLocally === true);
}
/**
* Download multiple selected files of the same version sequentially,
* reusing the location-step choices for every file. Per-file toasts,
@@ -1937,6 +1966,7 @@ export class DownloadManager {
await this._reconcileViewAfterDownload({
modelId: this.modelId,
isLatestVersion: this._isDownloadingLatestVersion(this.currentVersion?.id),
wasLocallyOwned: this._computeWasLocallyOwned(this.versions),
});
}
return failedItems.length === 0;
@@ -2521,6 +2551,7 @@ export class DownloadManager {
source: this.source,
fileParams,
closeModal: true,
wasLocallyOwned: this._computeWasLocallyOwned(this.versions),
});
}
+2 -2
View File
@@ -877,7 +877,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') {
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other' || this.currentPage === 'workflows') {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
}
@@ -977,7 +977,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') {
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other' || this.currentPage === 'workflows') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}
+13
View File
@@ -243,6 +243,18 @@ export class ModalManager {
});
}
// Add confirmationModal registration (generic JS-driven confirmation)
const confirmationModal = document.getElementById('confirmationModal');
if (confirmationModal) {
this.registerModal('confirmationModal', {
element: confirmationModal,
onClose: () => {
this.getModal('confirmationModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add deleteFolderModal registration
const deleteFolderModal = document.getElementById('deleteFolderModal');
if (deleteFolderModal) {
@@ -453,6 +465,7 @@ export class ModalManager {
id === "clearCacheModal" ||
id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal" ||
id === "confirmationModal" ||
id === "deleteFolderModal" ||
id === "resolveFilenameConflictsModal"
) {
+2 -2
View File
@@ -298,7 +298,7 @@ export class SearchManager {
pageState.searchOptions.loraName = options.loraName || false;
pageState.searchOptions.loraModel = options.loraModel || false;
pageState.searchOptions.prompt = options.prompt || false;
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') {
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other' || this.currentPage === 'workflows') {
// Update only the relevant fields in searchOptions instead of replacing the whole object
pageState.searchOptions.filename = options.filename || false;
pageState.searchOptions.modelname = options.modelname || false;
@@ -310,7 +310,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') {
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other' || this.currentPage === 'workflows') {
// For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false);
}
+163 -3
View File
@@ -1412,6 +1412,9 @@ export class SettingsManager {
await this.loadOtherRoots();
this.updateOtherModelsControls();
// Reflect the opt-in Workflow management toggle
this.updateWorkflowsControls();
// Load extra folder paths
this.loadExtraFolderPaths();
@@ -2439,6 +2442,12 @@ export class SettingsManager {
schema.forEach((entry) => {
if (entry.category === 'core') {
this._buildModelPathTypeGroup(coreHost, entry);
} else if (entry.category === 'workflow') {
// Opt-in like Other Models, but gated on its own master toggle
// (no sub-types) and rendered with the core groups.
if (state.global.settings.enable_workflows === true) {
this._buildModelPathTypeGroup(coreHost, entry);
}
} else if (otherModelsEnabled && entry.sub_type && enabledSubTypes.has(entry.sub_type)) {
otherCount++;
this._buildModelPathTypeGroup(otherHost, entry);
@@ -2585,7 +2594,11 @@ export class SettingsManager {
async updateModelFolderPaths(changedKey, { fromRemoval = false } = {}) {
this._clearAllModelFolderPathErrors();
const folderPaths = {};
// Start from the stored map so keys whose editor is not rendered
// (disabled other-model sub-types, workflows while the feature is
// off) survive a save instead of being wiped from the library.
const currentPaths = state.global.settings.folder_paths || {};
const folderPaths = { ...currentPaths };
const section = document.getElementById('section-modelPaths');
if (!section) return;
@@ -2624,7 +2637,6 @@ export class SettingsManager {
return;
}
const currentPaths = state.global.settings.folder_paths || {};
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(folderPaths);
if (!pathsChanged) {
@@ -3283,6 +3295,11 @@ export class SettingsManager {
this.removeOtherModelsAnnouncement(value);
}
if (settingKey === 'enable_workflows') {
this.updateWorkflowsControls();
this.updateWorkflowsNavVisibility(value);
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
// Apply frontend settings immediately
@@ -3399,6 +3416,138 @@ export class SettingsManager {
* its default-root select disabled. Never force-enables a select (the
* no-roots placeholder owns that state).
*/
/**
* Reflect the opt-in Workflow management state in the settings UI: the
* master toggle gates the workflow folder editor, which edits the active
* library's folder_paths.workflows entry (LoRA-Manager-owned in both
* plugin and standalone mode, applied live without a restart).
*/
updateWorkflowsControls() {
const enabled = !!state.global.settings.enable_workflows;
const toggle = document.getElementById('enableWorkflows');
if (toggle) {
toggle.checked = enabled;
}
const group = document.getElementById('workflowFolderPathsSetting');
if (group) {
group.style.display = enabled ? '' : 'none';
}
if (enabled) {
this.loadWorkflowFolderPaths();
}
}
loadWorkflowFolderPaths() {
const container = document.getElementById('workflowFolderPaths');
if (!container) return;
container.innerHTML = '';
const paths = state.global.settings.workflow_folder_paths || [];
paths.forEach((path) => {
this.addWorkflowFolderPathRow(path, false);
});
// No trailing empty row on load: an unconfigured library shows just
// its Add button, and removing a row never resurrects an empty one.
}
addWorkflowFolderPathRow(path = '', shouldFocus = true) {
const container = document.getElementById('workflowFolderPaths');
if (!container) return;
const row = document.createElement('div');
row.className = 'extra-folder-path-row mapping-row';
row.innerHTML = `
<div class="path-controls">
<input type="text" class="extra-folder-path-input"
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
onblur="settingsManager.updateWorkflowFolderPaths()"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="browse-path-btn"
onclick="settingsManager.browseForWorkflowPathRow(this)"
title="${translate('settings.directoryPicker.title', {}, 'Browse Folders')}">
<i class="fas fa-folder-open"></i>
</button>
<button type="button" class="remove-path-btn"
onclick="settingsManager.removeWorkflowFolderPathRow(this)"
title="${translate('common.actions.delete', {}, 'Delete')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
container.appendChild(row);
if (!path && shouldFocus) {
const input = row.querySelector('.extra-folder-path-input');
if (input) {
setTimeout(() => input.focus(), 0);
}
}
}
browseForWorkflowPathRow(btn) {
const row = btn.closest('.extra-folder-path-row');
const input = row ? row.querySelector('.extra-folder-path-input') : null;
if (!input) return;
directoryPickerModal.open({
initialPath: input.value.trim(),
onSelect: (pickedPath) => {
input.value = pickedPath;
this.updateWorkflowFolderPaths();
},
});
}
removeWorkflowFolderPathRow(btn) {
const row = btn.closest('.extra-folder-path-row');
if (row) {
row.remove();
this.updateWorkflowFolderPaths({ fromRemoval: true });
}
}
async updateWorkflowFolderPaths({ fromRemoval = false } = {}) {
const container = document.getElementById('workflowFolderPaths');
if (!container) return;
const paths = [];
container.querySelectorAll('.extra-folder-path-input').forEach((input) => {
const value = input.value.trim();
if (value) {
paths.push(value);
}
});
const currentPaths = state.global.settings.workflow_folder_paths || [];
if (JSON.stringify(currentPaths) === JSON.stringify(paths)) {
return;
}
try {
await this.saveSetting('workflow_folder_paths', paths);
state.global.settings.workflow_folder_paths = paths;
showToast('toast.settings.settingsUpdated', { setting: 'workflow folders' }, 'success');
// Keep the continuous-add flow: after the user fills the trailing
// empty row, append a fresh one — but never after a removal.
if (!fromRemoval) {
const inputs = container.querySelectorAll('.extra-folder-path-input');
const hasEmptyRow = Array.from(inputs).some((input) => !input.value.trim());
if (!hasEmptyRow) {
this.addWorkflowFolderPathRow('');
}
}
} catch (error) {
console.error('Failed to save workflow folder paths:', error);
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
this.loadWorkflowFolderPaths();
}
}
updateOtherModelsControls() {
const enableOtherModels = !!state.global.settings.enable_other_models;
const enabledSubTypes = new Set(
@@ -3410,7 +3559,6 @@ export class SettingsManager {
if (masterToggle) {
masterToggle.checked = enableOtherModels;
}
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
input.checked = enabledSubTypes.has(input.value);
input.disabled = !enableOtherModels;
@@ -3464,6 +3612,18 @@ export class SettingsManager {
}
}
/**
* Show or hide the Workflows nav entry. The nav is server-rendered, so
* toggling the class here keeps it in sync when the switch is flipped from
* the settings modal (no reload needed).
*/
updateWorkflowsNavVisibility(enabled) {
const navItem = document.getElementById('workflowsNavItem');
if (navItem) {
navItem.classList.toggle('nav-item--hidden', !enabled);
}
}
/**
* Drop the Other Models announcement banner once the feature is on.
*/
+40
View File
@@ -29,6 +29,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
default_embedding_root: '',
default_other_roots: {},
enable_other_models: false,
enable_workflows: false,
workflow_folder_paths: [],
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
recipes_path: '',
base_model_path_mappings: {},
@@ -107,6 +109,7 @@ const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
const otherPreviewVersions = getMapFromStorage('other_preview_versions');
const workflowPreviewVersions = getMapFromStorage('workflows_preview_versions');
export const state = {
// Global state
@@ -296,6 +299,43 @@ export const state = {
search: '',
},
activeViewSnapshot: null,
},
[MODEL_TYPES.WORKFLOW]: {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'name',
activeFolder: getStorageItem(`${MODEL_TYPES.WORKFLOW}_activeFolder`),
previewVersions: workflowPreviewVersions,
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
recursive: getStorageItem(`${MODEL_TYPES.WORKFLOW}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search: '',
tagLogic: 'any',
},
bulkMode: false,
selectedModels: new Set(),
metadataCache: new Map(),
showFavoritesOnly: false,
showUpdateAvailableOnly: false,
duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}
},
+3
View File
@@ -112,6 +112,8 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = {
text_encoder: "Text Encoder",
clip_vision: "CLIP Vision",
controlnet: "ControlNet",
// Workflow sub-types
workflow: "Workflow",
};
// Backward compatibility alias
@@ -130,6 +132,7 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = {
text_encoder: "TE",
clip_vision: "CV",
controlnet: "CN",
workflow: "WF",
};
export function getSubTypeAbbreviation(subType) {
+1 -1
View File
@@ -66,7 +66,7 @@ async function getCardCreator(pageType) {
// Function to get the appropriate data fetcher based on page type
async function getDataFetcher(pageType) {
if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints' || pageType === 'other') {
if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints' || pageType === 'other' || pageType === 'workflows') {
return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize);
} else if (pageType === 'recipes') {
// Import the recipeApi module and use the fetchRecipesPage function
+67 -2
View File
@@ -97,10 +97,10 @@ export function closeExcludeModal() {
export async function confirmExclude() {
if (!pendingExcludePath) return;
try {
await getModelApiClient().excludeModel(pendingExcludePath);
closeExcludeModal();
if (window.modelDuplicatesManager) {
@@ -110,3 +110,68 @@ export async function confirmExclude() {
console.error('Error excluding model:', error);
}
}
/**
* Show the generic confirmation modal (#confirmationModal) and resolve with
* the user's choice. Text is provided by the caller (already translated).
* @param {Object} options
* @param {string} options.title - Modal heading
* @param {string} options.message - Body text
* @param {string} options.confirmText - Confirm button label
* @param {boolean} [options.danger=false] - Style the confirm button as destructive
* @returns {Promise<boolean>} true when confirmed, false otherwise
*/
export function showConfirmationModal({ title, message, confirmText, danger = false }) {
const modalElement = document.getElementById('confirmationModal');
if (!modalElement) {
// Template missing (unexpected): fall back to the browser dialog.
return Promise.resolve(window.confirm(`${title}\n${message}`));
}
const titleElement = modalElement.querySelector('[data-role="title"]');
const messageElement = modalElement.querySelector('[data-role="message"]');
const confirmButton = modalElement.querySelector('[data-action="confirm-confirmation"]');
const cancelButton = modalElement.querySelector('[data-action="cancel-confirmation"]');
if (!titleElement || !messageElement || !confirmButton || !cancelButton) {
return Promise.resolve(window.confirm(`${title}\n${message}`));
}
titleElement.textContent = title;
messageElement.textContent = message;
confirmButton.textContent = confirmText;
confirmButton.classList.toggle('delete-btn', danger);
confirmButton.classList.toggle('primary-btn', !danger);
return new Promise((resolve) => {
let resolved = false;
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
cancelButton.removeEventListener('click', handleCancel);
};
const finalize = (proceed) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(proceed);
};
const handleConfirm = (event) => {
event.preventDefault();
finalize(true);
modalManager.closeModal('confirmationModal');
};
const handleCancel = (event) => {
event.preventDefault();
finalize(false);
modalManager.closeModal('confirmationModal');
};
confirmButton.addEventListener('click', handleConfirm);
cancelButton.addEventListener('click', handleCancel);
modalManager.showModal('confirmationModal', null, () => finalize(false));
});
}
+95
View File
@@ -0,0 +1,95 @@
import { MODEL_TYPES } from '../api/apiConfig.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { state } from '../state/index.js';
import { copyToClipboard, showToast } from './uiHelpers.js';
import { translate } from './i18nHelpers.js';
import { showConfirmationModal } from './modalUtils.js';
/**
* Push a library workflow into the running ComfyUI graph (plugin mode only;
* the backend rejects standalone mode since there is no graph to push into).
* @param {string} filePath
*/
export async function sendWorkflowToComfy(filePath) {
try {
await getModelApiClient(MODEL_TYPES.WORKFLOW).sendToComfy(filePath);
showToast('workflows.sendToComfy.success', {}, 'success');
} catch (error) {
console.error('Failed to send workflow to ComfyUI:', error);
showToast('workflows.sendToComfy.failed', { message: error.message }, 'error');
}
}
/**
* Copy the extracted workflow document to the clipboard so the user can
* paste it into ComfyUI (Ctrl+V) to load it. Works in standalone mode too.
* @param {string} filePath
*/
export async function copyWorkflowToClipboard(filePath) {
try {
const workflow = await getModelApiClient(MODEL_TYPES.WORKFLOW).fetchWorkflowContent(filePath);
await copyToClipboard(JSON.stringify(workflow));
} catch (error) {
console.error('Failed to copy workflow:', error);
showToast('workflows.copyWorkflow.failed', { message: error.message }, 'error');
}
}
/**
* Ask the ComfyUI tab that has this workflow open (and edited) to write its
* current graph back into the managed library document.
* @param {string} filePath
*/
export async function updateWorkflowFromComfy(filePath) {
try {
const result = await getModelApiClient(MODEL_TYPES.WORKFLOW).updateFromComfy(filePath);
showToast('workflows.updateFromComfy.success', {}, 'success');
// Re-render the card in place: the scroller shows its shared
// "Updated" cue and the item picks up the new document hash.
const updateData = result && result.sha256 ? { sha256: result.sha256 } : {};
state.virtualScroller?.updateSingleItem(filePath, updateData);
} catch (error) {
const kind = error.kind || 'unknown';
if (kind === 'no_changes' || kind === 'cancelled') {
const key = kind === 'no_changes' ? 'noChanges' : 'cancelled';
showToast(`workflows.updateFromComfy.${key}`, {}, 'info');
} else {
console.error('Failed to update workflow from ComfyUI:', error);
showToast('workflows.updateFromComfy.failed', { message: error.message }, 'error');
}
}
}
/**
* Restore a workflow to its downloaded state, discarding local edits. Bare
* .json artifacts are re-downloaded from the recorded CivitAI version;
* container artifacts (.zip/images) are re-extracted from the pristine file.
* @param {string} filePath
*/
export async function restoreWorkflow(filePath) {
const confirmed = await showConfirmationModal({
title: translate('workflows.restoreDownloaded.title', {}),
message: translate('workflows.restoreDownloaded.message', {}),
confirmText: translate('workflows.restoreDownloaded.confirmButton', {}),
danger: true,
});
if (!confirmed) {
return;
}
// .json restores re-download from CivitAI, which can take a moment; keep
// a blocking indicator up so the wait does not look like a dead click.
state.loadingManager?.showSimpleLoading(translate('workflows.restoreDownloaded.restoring', {}));
try {
const result = await getModelApiClient(MODEL_TYPES.WORKFLOW).restore(filePath);
showToast('workflows.restoreDownloaded.success', {}, 'success');
// Re-render the card in place: the scroller shows its shared
// "Updated" cue and the item picks up the restored document hash.
const updateData = result && result.sha256 ? { sha256: result.sha256 } : {};
state.virtualScroller?.updateSingleItem(filePath, updateData);
} catch (error) {
console.error('Failed to restore workflow:', error);
showToast('workflows.restoreDownloaded.failed', { message: error.message }, 'error');
} finally {
state.loadingManager?.hide();
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Shared helpers for the opt-in Workflow management feature.
*
* Used by the disabled page so that enabling the feature goes through the same
* settings API call as the Settings modal toggle.
*/
/**
* Turn on Workflow management and reload so the server-rendered nav and the
* scanner state pick up the change.
*/
export async function enableWorkflows() {
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable_workflows: true }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
window.location.reload();
}
/**
* Open the settings modal on the Library section and scroll the Workflows
* toggle into view.
*/
export function openWorkflowsSettings() {
const modalManager = window.modalManager;
if (modalManager && typeof modalManager.showModal === 'function') {
modalManager.showModal('settingsModal');
}
window.setTimeout(() => {
document.querySelectorAll('.settings-section').forEach((section) => {
section.classList.remove('active');
});
document.getElementById('section-library')?.classList.add('active');
document.querySelectorAll('.settings-nav-item').forEach((item) => {
item.classList.remove('active');
});
document.querySelector('.settings-nav-item[data-section="library"]')?.classList.add('active');
document.getElementById('enableWorkflows')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 100);
}
/**
* Open the settings modal on the standalone-only Model Paths section, where
* primary folder_paths are edited. The section only exists in standalone mode,
* so the nav item lookup simply no-ops elsewhere.
*/
export function openModelPathsSettings() {
const modalManager = window.modalManager;
if (modalManager && typeof modalManager.showModal === 'function') {
modalManager.showModal('settingsModal');
}
window.setTimeout(() => {
document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click();
}, 100);
}
+57
View File
@@ -0,0 +1,57 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the Workflows page
class WorkflowsPageManager {
constructor() {
// Initialize page controls
this.pageControls = createPageControls(MODEL_TYPES.WORKFLOW);
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.WORKFLOW);
// Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions();
}
_exposeRequiredGlobalFunctions() {
// Minimal set of functions that need to remain global
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync(MODEL_TYPES.WORKFLOW);
console.log('Workflows Manager initialized');
}
}
async function initializeWorkflowsPage() {
// Initialize core application
await appCore.initialize();
// Initialize workflows page
const workflowsPage = new WorkflowsPageManager();
await workflowsPage.initialize();
return workflowsPage;
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', initializeWorkflowsPage);
export { WorkflowsPageManager, initializeWorkflowsPage };
+109
View File
@@ -0,0 +1,109 @@
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
import { enableWorkflows, openWorkflowsSettings, openModelPathsSettings } from './utils/workflowModels.js';
/**
* Workflow management is an opt-in feature. While it is disabled this page
* renders an empty state whose button turns the feature on; the backend then
* rebuilds the workflow roots and starts scanning, so a reload lands on the
* real page.
*
* The same module backs the "enabled but no folders found" state: ComfyUI
* mode points to the Settings page's Library section, while standalone mode
* points to the standalone-only Model Paths section (which edits the primary
* folder_paths) and still offers the settings.json location as a fallback.
*/
async function handleEnableClick() {
const button = document.getElementById('enableWorkflowsBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
await enableWorkflows();
} catch (error) {
button.disabled = false;
showToast('workflows.disabled.enableFailed', { message: error.message }, 'error');
}
}
/**
* Open Settings on the Library section for the "no folders found" state, so a
* misconfigured install can be fixed without hand-editing unknown keys.
*/
function handleOpenSettingsClick(event) {
event.preventDefault();
openWorkflowsSettings();
}
/**
* Open Settings on the Model Paths section for the standalone "no folders
* found" state, so the missing folders can be added directly.
*/
function handleOpenModelPathsSettingsClick(event) {
event.preventDefault();
openModelPathsSettings();
}
/**
* Open the settings.json location from the standalone no-folders state,
* offered as a fallback next to the Model Paths settings button.
*/
async function handleOpenSettingsFolderClick() {
const button = document.getElementById('openSettingsFolderBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
const response = await fetch('/api/lm/settings/open-location', { method: 'POST' });
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success');
} catch (clipboardError) {
console.warn('Clipboard API not available:', clipboardError);
showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('settings.openSettingsFileLocation.success', {}, 'success');
}
} catch (error) {
console.error('Failed to open settings location:', error);
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
} finally {
button.disabled = false;
}
}
async function initializeWorkflowsDisabledPage() {
// appCore.initialize() wires the shared header (theme, settings modal,
// language) so this page is not a dead end.
await appCore.initialize();
const button = document.getElementById('enableWorkflowsBtn');
if (button) {
button.addEventListener('click', handleEnableClick);
}
const settingsButton = document.getElementById('openWorkflowsSettingsBtn');
if (settingsButton) {
settingsButton.addEventListener('click', handleOpenSettingsClick);
}
const modelPathsButton = document.getElementById('openModelPathsSettingsBtn');
if (modelPathsButton) {
modelPathsButton.addEventListener('click', handleOpenModelPathsSettingsClick);
}
const settingsFolderButton = document.getElementById('openSettingsFolderBtn');
if (settingsFolderButton) {
settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick);
}
}
document.addEventListener('DOMContentLoaded', initializeWorkflowsDisabledPage);
export { handleEnableClick as enableWorkflows, handleOpenSettingsFolderClick, initializeWorkflowsDisabledPage };
+6
View File
@@ -17,6 +17,8 @@
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/other') %}
{% set current_page = 'other' %}
{% elif current_path.startswith('/workflows') %}
{% set current_page = 'workflows' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
@@ -42,6 +44,10 @@
id="otherNavItem">
<i class="fas fa-shapes"></i> <span>{{ t('header.navigation.other') }}</span>
</a>
<a href="{{ rel_prefix }}workflows" class="nav-item{% if current_path.startswith('/workflows') %} active{% endif %}{% if not settings.get('enable_workflows') %} nav-item--hidden{% endif %}"
id="workflowsNavItem">
<i class="fas fa-diagram-project"></i> <span>{{ t('header.navigation.workflows') }}</span>
</a>
<a href="{{ rel_prefix }}statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
@@ -82,6 +82,19 @@
</div>
</div>
<!-- Generic Confirmation Modal (title/message/confirm text are filled by JS;
use showConfirmationModal() from utils/modalUtils.js) -->
<div id="confirmationModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-confirmation">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" data-action="confirm-confirmation"></button>
</div>
</div>
</div>
<!-- Filename Template Apply/Revert Confirmation Modal
Self-managed by SettingsManager (NOT registered with ModalManager): it
stacks above the settings modal, like the directory picker. -->
@@ -90,6 +90,27 @@
</div>
</div>
{% endfor %}
{{ sm.setting_toggle('enableWorkflows', 'enable_workflows', 'settings.folderSettings.enableWorkflows', 'settings.folderSettings.enableWorkflowsHelp') }}
<div class="setting-item" id="workflowFolderPathsSetting">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.folderSettings.workflowFolders') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.workflowFoldersHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addWorkflowFolderPathRow()">
<i class="fas fa-plus"></i>
<span>{{ t('common.actions.add') }}</span>
</button>
</div>
</div>
<div class="extra-folder-paths-container" id="workflowFolderPaths">
</div>
</div>
</div>
<!-- Recipe Settings -->
+161
View File
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}{{ t('workflows.title') }}{% endblock %}
{% block page_id %}workflows{% endblock %}
{% block init_title %}{{ t('initialization.workflows.title') }}{% endblock %}
{% block init_message %}{{ t('initialization.workflows.message') }}{% endblock %}
{% block init_check_url %}/api/lm/workflows/list?page=1&page_size=1{% endblock %}
{% block page_css %}
<style>
.workflows-disabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 64px 24px;
color: var(--text-color, #e0e0e0);
}
.workflows-disabled > i {
font-size: 48px;
opacity: 0.6;
}
.workflows-disabled h2 {
margin: 0;
font-size: 20px;
}
.workflows-disabled p {
margin: 0;
max-width: 520px;
line-height: 1.6;
opacity: 0.8;
}
.workflows-disabled .workflows-disabled-hint {
font-size: 13px;
opacity: 0.6;
}
.workflows-disabled button {
margin-top: 8px;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #fff;
background: var(--primary-color, #4a7dff);
}
.workflows-disabled button:disabled {
opacity: 0.6;
cursor: default;
}
.workflows-settings-file {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.workflows-settings-file code {
padding: 4px 8px;
border-radius: 4px;
background: rgba(127, 127, 127, 0.15);
border: 1px solid rgba(127, 127, 127, 0.25);
word-break: break-all;
}
</style>
{% endblock %}
{% block additional_components %}
<div id="workflowContextMenu" class="context-menu" style="display: none;">
<!-- Open -->
<div class="context-menu-item" data-action="send-to-comfy"><i class="fas fa-paper-plane"></i> {{ t('workflows.contextMenu.sendToComfy') }}</div>
<div class="context-menu-item disabled" data-action="update-from-comfy"><i class="fas fa-download"></i> {{ t('workflows.contextMenu.updateFromComfy') }}</div>
<div class="context-menu-item" data-action="copy-workflow"><i class="fas fa-clipboard"></i> {{ t('workflows.contextMenu.copyWorkflow') }}</div>
<div class="context-menu-item" data-action="restore-download"><i class="fas fa-undo"></i> {{ t('workflows.contextMenu.restoreDownloaded') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Metadata -->
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-external-link-alt"></i> {{ t('loras.contextMenu.linkCivitai') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview -->
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Attributes -->
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Organization -->
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.moveToFolder') }}</div>
<div class="context-menu-separator"></div>
<!-- Destructive -->
<div class="context-menu-item" data-action="exclude"><i class="fas fa-eye-slash"></i> {{ t('loras.contextMenu.excludeModel') }}</div>
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteModel') }}</div>
</div>
{% endblock %}
{% block content %}
{% if workflows_disabled %}
<div class="workflows-disabled">
<i class="fas fa-diagram-project"></i>
<h2>{{ t('workflows.disabled.title') }}</h2>
<p>{{ t('workflows.disabled.description') }}</p>
<button id="enableWorkflowsBtn" type="button">
<i class="fas fa-toggle-on"></i> {{ t('workflows.disabled.enableButton') }}
</button>
<p class="workflows-disabled-hint">{{ t('workflows.disabled.hint') }}</p>
</div>
{% elif workflows_no_paths %}
<div class="workflows-disabled">
<i class="fas fa-folder-open"></i>
<h2>{{ t('workflows.noPaths.title') }}</h2>
{% if standalone_mode %}
<p>{{ t('workflows.noPaths.descriptionStandalone') }}</p>
<p class="workflows-disabled-hint">{{ t('workflows.noPaths.hintStandalone') }}</p>
<button id="openModelPathsSettingsBtn" type="button">
<i class="fas fa-cog"></i> {{ t('workflows.noPaths.openModelPaths') }}
</button>
{% if settings_file %}
<p class="workflows-settings-file"><i class="fas fa-file-alt"></i> <code>{{ settings_file }}</code></p>
{% endif %}
<button id="openSettingsFolderBtn" type="button">
<i class="fas fa-folder-open"></i> {{ t('workflows.noPaths.openSettingsFolder') }}
</button>
{% else %}
<p>{{ t('workflows.noPaths.descriptionComfyUI') }}</p>
<p class="workflows-disabled-hint">{{ t('workflows.noPaths.hintComfyUI') }}</p>
<button id="openWorkflowsSettingsBtn" type="button">
<i class="fas fa-cog"></i> {{ t('workflows.noPaths.openSettings') }}
</button>
{% endif %}
</div>
{% else %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
<!-- Workflow cards container -->
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
{% endif %}
{% endblock %}
{% block overlay %}
<div class="bulk-mode-overlay"></div>
{% endblock %}
{% block main_script %}
{% if workflows_disabled or workflows_no_paths %}
<script type="module" src="{{ rel_prefix }}loras_static/js/workflows_disabled.js?v={{ version }}"></script>
{% else %}
<script type="module" src="{{ rel_prefix }}loras_static/js/workflows.js?v={{ version }}"></script>
{% endif %}
{% endblock %}
+2
View File
@@ -21,6 +21,8 @@ def _make_config(**overrides) -> config_module.Config:
config._cached_fingerprint = None
config.base_models_roots = []
config.embeddings_roots = []
config.workflows_roots = []
config.extra_workflows_roots = []
for key, value in overrides.items():
setattr(config, key, value)
return config
@@ -0,0 +1,308 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const copyToClipboardMock = vi.fn();
const fetchWorkflowContentMock = vi.fn();
const sendToComfyMock = vi.fn();
const fetchComfyuiStateMock = vi.fn();
const updateFromComfyMock = vi.fn();
const restoreMock = vi.fn();
const resetAndReloadMock = vi.fn();
const updateSingleItemMock = vi.fn();
const showConfirmationModalMock = vi.fn();
const showSimpleLoadingMock = vi.fn();
const hideLoadingMock = vi.fn();
const stateStub = {
global: { settings: { standalone_mode: false } },
currentPageType: 'workflows',
pages: {},
virtualScroller: { updateSingleItem: updateSingleItemMock, items: [] },
loadingManager: { showSimpleLoading: showSimpleLoadingMock, hide: hideLoadingMock },
};
vi.mock('../../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: copyToClipboardMock,
}));
vi.mock('../../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => ({
fetchWorkflowContent: fetchWorkflowContentMock,
sendToComfy: sendToComfyMock,
fetchComfyuiState: fetchComfyuiStateMock,
updateFromComfy: updateFromComfyMock,
restore: restoreMock,
})),
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key) => key,
}));
vi.mock('../../../../static/js/managers/MoveManager.js', () => ({
moveManager: { showMoveModal: vi.fn() },
}));
vi.mock('../../../../static/js/utils/modalUtils.js', () => ({
showDeleteModal: vi.fn(),
showExcludeModal: vi.fn(),
showConfirmationModal: showConfirmationModalMock,
}));
vi.mock('../../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
ModelContextMenuMixin: {
handleCommonMenuActions: vi.fn(() => false),
updateExcludeMenuItem: vi.fn(),
initNSFWSelector: vi.fn(),
},
}));
const { WorkflowContextMenu } = await import(
'../../../../static/js/components/ContextMenu/WorkflowContextMenu.js'
);
function setupDom() {
document.body.innerHTML = `
<div id="workflowContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="send-to-comfy"></div>
<div class="context-menu-item disabled" data-action="update-from-comfy"></div>
<div class="context-menu-item" data-action="copy-workflow"></div>
<div class="context-menu-item" data-action="restore-download"></div>
</div>
<div class="model-card" data-filepath="/library/workflows/flow.json"></div>
<select id="nsfwLevelSelector"></select>
`;
}
describe('WorkflowContextMenu', () => {
let menu;
let card;
beforeEach(() => {
vi.clearAllMocks();
stateStub.global.settings.standalone_mode = false;
stateStub.virtualScroller.items = [];
fetchComfyuiStateMock.mockResolvedValue([]);
setupDom();
menu = new WorkflowContextMenu();
card = document.querySelector('.model-card');
menu.currentCard = card;
});
it('copies the workflow document to the clipboard', async () => {
const workflow = { 1: { class_type: 'CheckpointLoaderSimple', inputs: {} } };
fetchWorkflowContentMock.mockResolvedValue(workflow);
menu.handleMenuAction('copy-workflow');
await vi.waitFor(() => expect(copyToClipboardMock).toHaveBeenCalled());
expect(fetchWorkflowContentMock).toHaveBeenCalledWith('/library/workflows/flow.json');
expect(copyToClipboardMock).toHaveBeenCalledWith(JSON.stringify(workflow));
expect(showToastMock).not.toHaveBeenCalledWith(
'workflows.copyWorkflow.failed',
expect.anything(),
'error'
);
});
it('shows an error toast when the workflow content cannot be loaded', async () => {
fetchWorkflowContentMock.mockRejectedValue(new Error('not_a_workflow'));
menu.handleMenuAction('copy-workflow');
await vi.waitFor(() =>
expect(showToastMock).toHaveBeenCalledWith(
'workflows.copyWorkflow.failed',
{ message: 'not_a_workflow' },
'error'
)
);
expect(copyToClipboardMock).not.toHaveBeenCalled();
});
it('hides send-to-comfy in standalone mode', () => {
stateStub.global.settings.standalone_mode = true;
menu.showMenu(10, 10, card);
const sendItem = document.querySelector('[data-action="send-to-comfy"]');
expect(sendItem.style.display).toBe('none');
});
it('keeps send-to-comfy visible in plugin mode', () => {
menu.showMenu(10, 10, card);
const sendItem = document.querySelector('[data-action="send-to-comfy"]');
expect(sendItem.style.display).toBe('');
});
it('hides update-from-comfy in standalone mode', () => {
stateStub.global.settings.standalone_mode = true;
menu.showMenu(10, 10, card);
const updateItem = document.querySelector('[data-action="update-from-comfy"]');
expect(updateItem.style.display).toBe('none');
});
it('keeps update-from-comfy disabled when the workflow is not open in ComfyUI', async () => {
fetchComfyuiStateMock.mockResolvedValue([]);
menu.showMenu(10, 10, card);
await vi.waitFor(() => expect(fetchComfyuiStateMock).toHaveBeenCalled());
const updateItem = document.querySelector('[data-action="update-from-comfy"]');
expect(updateItem.classList.contains('disabled')).toBe(true);
});
it('keeps update-from-comfy disabled when the open workflow has no edits', async () => {
fetchComfyuiStateMock.mockResolvedValue([
{ client_id: 'c1', file_path: '/library/workflows/flow.json', dirty: false },
]);
menu.showMenu(10, 10, card);
await vi.waitFor(() => expect(fetchComfyuiStateMock).toHaveBeenCalled());
const updateItem = document.querySelector('[data-action="update-from-comfy"]');
expect(updateItem.classList.contains('disabled')).toBe(true);
});
it('enables update-from-comfy when the workflow is open in ComfyUI with edits', async () => {
fetchComfyuiStateMock.mockResolvedValue([
{ client_id: 'c1', file_path: '/library/workflows/flow.json', dirty: true },
]);
menu.showMenu(10, 10, card);
await vi.waitFor(() => {
const updateItem = document.querySelector('[data-action="update-from-comfy"]');
expect(updateItem.classList.contains('disabled')).toBe(false);
});
});
it('triggers the write-back flow on update-from-comfy', async () => {
updateFromComfyMock.mockResolvedValue({ success: true, sha256: 'newhash123' });
menu.handleMenuAction('update-from-comfy');
await vi.waitFor(() =>
expect(updateFromComfyMock).toHaveBeenCalledWith('/library/workflows/flow.json')
);
await vi.waitFor(() =>
expect(showToastMock).toHaveBeenCalledWith(
'workflows.updateFromComfy.success',
{},
'success'
)
);
// The card is refreshed in place so it shows the "Updated" cue.
expect(updateSingleItemMock).toHaveBeenCalledWith('/library/workflows/flow.json', {
sha256: 'newhash123',
});
});
it('surfaces no_changes as an info toast', async () => {
const error = new Error('no_changes');
error.kind = 'no_changes';
updateFromComfyMock.mockRejectedValue(error);
menu.handleMenuAction('update-from-comfy');
await vi.waitFor(() =>
expect(showToastMock).toHaveBeenCalledWith(
'workflows.updateFromComfy.noChanges',
{},
'info'
)
);
});
it('disables restore-download for local-only .json workflows', () => {
stateStub.virtualScroller.items = [
{ file_path: '/library/workflows/flow.json', from_civitai: false },
];
menu.showMenu(10, 10, card);
const restoreItem = document.querySelector('[data-action="restore-download"]');
expect(restoreItem.classList.contains('disabled')).toBe(true);
});
it('enables restore-download for CivitAI-sourced .json workflows', () => {
stateStub.virtualScroller.items = [
{ file_path: '/library/workflows/flow.json', from_civitai: true },
];
menu.showMenu(10, 10, card);
const restoreItem = document.querySelector('[data-action="restore-download"]');
expect(restoreItem.classList.contains('disabled')).toBe(false);
});
it('enables restore-download for container artifacts without CivitAI data', () => {
card.dataset.filepath = '/library/workflows/pack.zip';
menu.showMenu(10, 10, card);
const restoreItem = document.querySelector('[data-action="restore-download"]');
expect(restoreItem.classList.contains('disabled')).toBe(false);
});
it('restores the workflow on restore-download after confirmation', async () => {
showConfirmationModalMock.mockResolvedValue(true);
restoreMock.mockResolvedValue({ success: true, restored: 'downloaded', sha256: 'restoredsha' });
menu.handleMenuAction('restore-download');
await vi.waitFor(() =>
expect(restoreMock).toHaveBeenCalledWith('/library/workflows/flow.json')
);
await vi.waitFor(() =>
expect(showToastMock).toHaveBeenCalledWith(
'workflows.restoreDownloaded.success',
{},
'success'
)
);
// The confirmation goes through the app's modal, not the browser dialog.
expect(showConfirmationModalMock).toHaveBeenCalledWith(
expect.objectContaining({ danger: true })
);
// A blocking indicator covers the (potentially slow) re-download.
expect(showSimpleLoadingMock).toHaveBeenCalled();
expect(hideLoadingMock).toHaveBeenCalled();
// The card is refreshed in place so it shows the "Updated" cue.
expect(updateSingleItemMock).toHaveBeenCalledWith('/library/workflows/flow.json', {
sha256: 'restoredsha',
});
});
it('does not call restore when the confirmation is declined', async () => {
showConfirmationModalMock.mockResolvedValue(false);
menu.handleMenuAction('restore-download');
await vi.waitFor(() => expect(showConfirmationModalMock).toHaveBeenCalled());
expect(restoreMock).not.toHaveBeenCalled();
expect(showSimpleLoadingMock).not.toHaveBeenCalled();
});
it('shows an error toast when restore fails and still hides the loading', async () => {
showConfirmationModalMock.mockResolvedValue(true);
const error = new Error('Workflow has no recorded CivitAI source to restore from');
error.kind = 'no_source';
restoreMock.mockRejectedValue(error);
menu.handleMenuAction('restore-download');
await vi.waitFor(() =>
expect(showToastMock).toHaveBeenCalledWith(
'workflows.restoreDownloaded.failed',
{ message: 'Workflow has no recorded CivitAI source to restore from' },
'error'
)
);
expect(hideLoadingMock).toHaveBeenCalled();
});
});
@@ -289,6 +289,54 @@ describe("LoraManager.WorkflowRegistry", () => {
expect(appMock.loadApiJson).not.toHaveBeenCalled();
});
it("stamps the lm_source marker into extra for UI-format workflows", async () => {
const workflow = { nodes: [{ id: 1 }], links: [], extra: { ds: { scale: 1 } } };
await extension.loadWorkflowFromMessage({
workflow,
name: "Managed",
lm_source: { file_path: "/lib/flow.json", sha256: "abc123" },
});
const loaded = appMock.loadGraphData.mock.calls[0][0];
expect(loaded.extra.lm_source).toEqual({
file_path: "/lib/flow.json",
sha256: "abc123",
});
// Pre-existing extra keys are preserved.
expect(loaded.extra.ds).toEqual({ scale: 1 });
});
it("stamps the lm_source marker onto the live graph for API-format workflows", async () => {
const rootGraph = { extra: undefined };
appMock.rootGraph = rootGraph;
try {
const workflow = { "1": { class_type: "KSampler", inputs: {} } };
await extension.loadWorkflowFromMessage({
workflow,
name: "Managed API",
lm_source: { file_path: "/lib/flow.json", sha256: null },
});
expect(rootGraph.extra.lm_source).toEqual({
file_path: "/lib/flow.json",
sha256: null,
});
} finally {
delete appMock.rootGraph;
}
});
it("does not add a marker when the payload has no lm_source", async () => {
const workflow = { nodes: [{ id: 1 }], links: [] };
await extension.loadWorkflowFromMessage({ workflow, name: "Plain" });
const loaded = appMock.loadGraphData.mock.calls[0][0];
expect(loaded.extra).toBeUndefined();
});
it("defaults the workflow name to 'Recipe Workflow'", async () => {
const workflow = { nodes: [], links: [] };
@@ -0,0 +1,310 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
WRITEBACK_MODULE,
appMock,
apiMock,
toastMock,
registeredExtensions,
} = vi.hoisted(() => {
const registeredExtensions = [];
const appMock = {
rootGraph: null,
graph: null,
registerExtension: (ext) => registeredExtensions.push(ext),
};
const apiMock = {
clientId: "client-1",
initialClientId: null,
listeners: {},
addEventListener: (event, handler) => {
(apiMock.listeners[event] ??= []).push(handler);
},
};
const toastMock = vi.fn();
return {
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
WRITEBACK_MODULE: new URL("../../../web/comfyui/workflow_writeback.js", import.meta.url).pathname,
appMock,
apiMock,
toastMock,
registeredExtensions,
};
});
vi.mock(APP_MODULE, () => ({ app: appMock }));
vi.mock(API_MODULE, () => ({ api: apiMock }));
vi.mock(UTILS_MODULE, () => ({ showToast: toastMock }));
function createRootGraph({ marker = null, serialized = null } = {}) {
const extra = marker ? { lm_source: marker, ds: { scale: 1 } } : { ds: { scale: 1 } };
return {
extra,
serialize: vi.fn(() => serialized ?? { nodes: [], links: [], extra: JSON.parse(JSON.stringify(extra)) }),
};
}
function fetchCalls(fetchMock, url) {
return fetchMock.mock.calls.filter(([callUrl]) => callUrl === url);
}
function okResponse(body = { success: true, sha256: "new-hash" }) {
return { status: 200, json: async () => body };
}
describe("LoraManager.WorkflowWriteback", () => {
let extension;
let moduleExports;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
registeredExtensions.length = 0;
apiMock.listeners = {};
appMock.rootGraph = null;
appMock.graph = null;
toastMock.mockClear();
fetchMock = vi.fn().mockResolvedValue(okResponse());
global.fetch = fetchMock;
moduleExports = await import(WRITEBACK_MODULE);
extension = registeredExtensions.find(
(ext) => ext.name === "LoraManager.WorkflowWriteback"
);
expect(extension).toBeDefined();
});
it("registers the write-back command in the File menu group", () => {
expect(extension.commands).toHaveLength(1);
expect(extension.commands[0].id).toBe("LoraManager.UpdateManagedWorkflow");
expect(extension.menuCommands).toEqual([
{ path: ["File"], commands: ["LoraManager.UpdateManagedWorkflow"] },
]);
});
describe("getActiveLmSource", () => {
it("returns null when the graph carries no marker", () => {
appMock.rootGraph = createRootGraph();
expect(moduleExports.getActiveLmSource()).toBeNull();
});
it("returns the marker when present", () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
expect(moduleExports.getActiveLmSource()).toBe(marker);
});
it("returns null when the marker has no usable file_path", () => {
appMock.rootGraph = createRootGraph({ marker: { sha256: "abc" } });
expect(moduleExports.getActiveLmSource()).toBeNull();
});
});
describe("link state reporting", () => {
it("reports the marker state after a graph load (afterConfigureGraph)", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
await extension.afterConfigureGraph();
const reports = fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state");
expect(reports).toHaveLength(1);
const body = JSON.parse(reports[0][1].body);
expect(body).toEqual({
client_id: "client-1",
state: { file_path: "/lib/flow.json", sha256: "abc", dirty: false },
});
});
it("reports a null state when the active graph is not LM-linked", async () => {
appMock.rootGraph = createRootGraph();
await extension.afterConfigureGraph();
const reports = fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state");
const body = JSON.parse(reports[0][1].body);
expect(body.state).toBeNull();
});
it("marks the graph dirty and reports on the first graphChanged", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc" };
appMock.rootGraph = createRootGraph({ marker });
extension.setup();
const handler = apiMock.listeners["graphChanged"][0];
handler({});
expect(marker.dirty).toBe(true);
await vi.waitFor(() =>
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(1)
);
const body = JSON.parse(
fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")[0][1].body
);
expect(body.state.dirty).toBe(true);
// A second change does not re-report (already dirty).
handler({});
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(1);
});
it("ignores graphChanged when the active graph is not LM-linked", () => {
appMock.rootGraph = createRootGraph();
extension.setup();
apiMock.listeners["graphChanged"][0]({});
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(0);
});
});
describe("updateManagedWorkflow", () => {
it("warns and does not call the backend without a marker", async () => {
appMock.rootGraph = createRootGraph();
await moduleExports.updateManagedWorkflow();
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(0);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "warn" })
);
});
it("PUTs the serialized graph without the marker and refreshes the hash", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "old-hash", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
await moduleExports.updateManagedWorkflow();
const puts = fetchCalls(fetchMock, "/api/lm/workflows/content");
expect(puts).toHaveLength(1);
const [url, options] = puts[0];
expect(url).toBe("/api/lm/workflows/content");
expect(options.method).toBe("PUT");
const body = JSON.parse(options.body);
expect(body.file_path).toBe("/lib/flow.json");
expect(body.base_sha256).toBe("old-hash");
expect(body.force).toBe(false);
expect(body.workflow.extra.lm_source).toBeUndefined();
// Other extra keys survive the strip.
expect(body.workflow.extra.ds).toEqual({ scale: 1 });
expect(marker.sha256).toBe("new-hash");
// Write-back clears the dirty flag and re-reports the link state.
expect(marker.dirty).toBe(false);
await vi.waitFor(() =>
expect(fetchCalls(fetchMock, "/api/lm/workflows/comfyui-state")).toHaveLength(1)
);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "success" })
);
});
it("retries with force after a conflict only when the user confirms", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "old-hash", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
const confirmMock = vi.fn().mockReturnValue(false);
vi.stubGlobal("confirm", confirmMock);
fetchMock.mockImplementation(async (url) =>
url === "/api/lm/workflows/content"
? { status: 409, json: async () => ({ success: false, error: "conflict" }) }
: okResponse()
);
await moduleExports.updateManagedWorkflow();
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(1);
expect(toastMock).not.toHaveBeenCalledWith(
expect.objectContaining({ severity: "success" })
);
confirmMock.mockReturnValue(true);
let conflictServed = false;
fetchMock.mockImplementation(async (url) => {
if (url !== "/api/lm/workflows/content") return okResponse();
if (!conflictServed) {
conflictServed = true;
return { status: 409, json: async () => ({ success: false, error: "conflict" }) };
}
return okResponse({ success: true, sha256: "forced-hash" });
});
await moduleExports.updateManagedWorkflow();
const puts = fetchCalls(fetchMock, "/api/lm/workflows/content");
expect(puts).toHaveLength(3);
const forcedBody = JSON.parse(puts[2][1].body);
expect(forcedBody.force).toBe(true);
expect(marker.sha256).toBe("forced-hash");
vi.unstubAllGlobals();
});
it("reports backend errors via toast", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
fetchMock.mockResolvedValue({
status: 400,
json: async () => ({ success: false, error: "workflow is not a ComfyUI graph document" }),
});
await moduleExports.updateManagedWorkflow();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ severity: "error" })
);
});
});
describe("lm_collect_workflow handling", () => {
function fireCollect(detail) {
extension.setup();
const handler = apiMock.listeners["lm_collect_workflow"][0];
return handler({ detail });
}
it("ignores requests for workflows this tab does not hold", async () => {
const marker = { file_path: "/lib/other.json", sha256: "abc", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
await fireCollect({ request_id: "r1", file_path: "/lib/flow.json" });
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(0);
expect(fetchCalls(fetchMock, "/api/lm/workflows/collect-result")).toHaveLength(0);
});
it("answers no_changes without writing back when the graph is clean", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "abc", dirty: false };
appMock.rootGraph = createRootGraph({ marker });
await fireCollect({ request_id: "r2", file_path: "/lib/flow.json" });
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(0);
const results = fetchCalls(fetchMock, "/api/lm/workflows/collect-result");
expect(results).toHaveLength(1);
expect(JSON.parse(results[0][1].body)).toEqual({
request_id: "r2",
success: false,
error: "no_changes",
});
});
it("writes back and posts the outcome when the graph is dirty", async () => {
const marker = { file_path: "/lib/flow.json", sha256: "old-hash", dirty: true };
appMock.rootGraph = createRootGraph({ marker });
await fireCollect({ request_id: "r3", file_path: "/lib/flow.json" });
expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(1);
const results = fetchCalls(fetchMock, "/api/lm/workflows/collect-result");
expect(results).toHaveLength(1);
expect(JSON.parse(results[0][1].body)).toEqual({
request_id: "r3",
success: true,
sha256: "new-hash",
});
expect(marker.dirty).toBe(false);
});
});
});
@@ -209,8 +209,25 @@ describe('DownloadManager post-download in-place reconciliation (#1078)', () =>
});
});
describe('_reconcileViewAfterDownload', () => {
it('removes the model cards in the Updates view when the latest version was installed', async () => {
describe('_computeWasLocallyOwned', () => {
it('returns null when no version list is available', () => {
expect(manager._computeWasLocallyOwned(null)).toBe(null);
expect(manager._computeWasLocallyOwned(undefined)).toBe(null);
expect(manager._computeWasLocallyOwned([])).toBe(null);
});
it('returns true when any version was already local', () => {
const versions = [{ id: 1, existsLocally: false }, { id: 2, existsLocally: true }];
expect(manager._computeWasLocallyOwned(versions)).toBe(true);
});
it('returns false when no version was local (brand-new model)', () => {
const versions = [{ id: 1, existsLocally: false }, { id: 2, existsLocally: false }];
expect(manager._computeWasLocallyOwned(versions)).toBe(false);
});
});
describe('_reconcileViewAfterDownload', () => { it('removes the model cards in the Updates view when the latest version was installed', async () => {
stateMock.virtualScroller.items = [
makeItem('/models/loras/old.safetensors', 837884),
makeItem('/models/loras/unrelated.safetensors', 999999),
@@ -260,11 +277,15 @@ describe('DownloadManager post-download in-place reconciliation (#1078)', () =>
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
});
it('keeps the listing untouched when the downloaded model is not in the current view', async () => {
it('keeps the listing untouched when an owned model is not in the current view', async () => {
stateMock.virtualScroller.items = [makeItem('/models/loras/other.safetensors', 999999)];
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true });
const result = await manager._reconcileViewAfterDownload({
modelId: 837884,
isLatestVersion: true,
wasLocallyOwned: true,
});
expect(result).toBe(false);
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
@@ -273,6 +294,34 @@ describe('DownloadManager post-download in-place reconciliation (#1078)', () =>
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
});
it('reloads the listing when a brand-new model has no card to reconcile', async () => {
stateMock.virtualScroller.items = [makeItem('/models/loras/other.safetensors', 999999)];
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
const result = await manager._reconcileViewAfterDownload({
modelId: 837884,
isLatestVersion: true,
wasLocallyOwned: false,
});
expect(result).toBe(true);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
expect(mockScroller.updateSingleItem).not.toHaveBeenCalled();
expect(sidebarRefreshMock).not.toHaveBeenCalled();
});
it('reloads the listing when ownership is unknown and no card matches', async () => {
stateMock.virtualScroller.items = [makeItem('/models/loras/other.safetensors', 999999)];
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true });
expect(result).toBe(true);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
expect(sidebarRefreshMock).not.toHaveBeenCalled();
});
it('no-ops for models without a CivitAI identity (HF downloads)', async () => {
stateMock.virtualScroller.items = [makeItem('/models/loras/a.safetensors', 123)];
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
@@ -36,6 +36,8 @@
'sidecar_storage_root_is_default': True,
'standalone_mode': False,
'theme': 'dark',
'workflow_folder_paths': list([
]),
}),
'success': True,
})
+21 -2
View File
@@ -60,6 +60,25 @@ async def test_browse_directory_lists_subdirs_sorted_and_filters(tmp_path):
assert payload["directory_count"] == 2
@pytest.mark.asyncio
async def test_browse_directory_preserves_symlinked_path(tmp_path):
# The picker result is stored as a configured library path (a business
# path): browsing through a symlink must keep the link path instead of
# resolving to the target.
real = tmp_path / "real-target"
real.mkdir()
(real / "child").mkdir()
link = tmp_path / "link"
link.symlink_to(real)
response, payload = await _browse(_make_handler(), str(link))
assert response.status == 200
assert payload["success"] is True
assert payload["current_path"] == str(link)
assert payload["directories"][0]["path"] == str(link / "child")
@pytest.mark.asyncio
async def test_browse_directory_missing_returns_404(tmp_path):
response, payload = await _browse(_make_handler(), str(tmp_path / "nope"))
@@ -81,9 +100,9 @@ async def test_browse_directory_file_path_returns_400(tmp_path):
@pytest.mark.asyncio
async def test_browse_directory_relative_path_returns_403(monkeypatch):
# resolve() normally absolutizes relative paths against the cwd; bypass it
# abspath() normally absolutizes relative paths against the cwd; bypass it
# to exercise the access-denied branch directly.
monkeypatch.setattr(Path, "resolve", lambda self: self)
monkeypatch.setattr(os.path, "abspath", lambda p: p)
response, payload = await _browse(_make_handler(), "relative/path")
+3
View File
@@ -80,6 +80,9 @@ class DummySettings:
def is_other_models_enabled(self):
return bool(self.data.get("enable_other_models", False))
def is_workflows_enabled(self):
return bool(self.data.get("enable_workflows", False))
def get_enabled_other_sub_types(self):
return list(self.data.get("enabled_other_sub_types") or [])
+58
View File
@@ -291,3 +291,61 @@ async def test_get_settings_standalone_filters_template_placeholders(
"loras": ["/real/loras"],
"vae": [],
}
@pytest.mark.asyncio
async def test_get_settings_exposes_workflow_folder_paths_in_plugin_mode(
monkeypatch, patch_other_models_availability
):
"""Workflow roots are LoRA-Manager-owned in both modes, so they sync to
the frontend even where the host-owned folder_paths stay hidden."""
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
handler = make_get_handler({"language": "en"})
handler._settings.get_workflow_folder_paths = lambda: ["/library/workflows"]
response = await handler.get_settings(FakeRequest())
payload = json_payload(response)
assert response.status == 200
settings = payload["settings"]
assert settings["standalone_mode"] is False
assert "folder_paths" not in settings
assert settings["workflow_folder_paths"] == ["/library/workflows"]
@pytest.mark.asyncio
async def test_get_settings_workflow_folder_paths_fallback_when_stub_lacks_method(
monkeypatch, patch_other_models_availability
):
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
handler = make_get_handler({"language": "en"})
response = await handler.get_settings(FakeRequest())
payload = json_payload(response)
assert response.status == 200
assert payload["settings"]["workflow_folder_paths"] == []
@pytest.mark.asyncio
async def test_update_settings_routes_workflow_folder_paths_to_dedicated_merge(
monkeypatch, patch_other_models_availability
):
"""The pseudo-key must merge into folder_paths server-side instead of
replacing the whole (partially host-owned) map via a generic set()."""
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
handler = make_get_handler({"language": "en"})
recorded = []
handler._settings.set_workflow_folder_paths = lambda value: recorded.append(value) or value
response = await handler.update_settings(
FakeRequest(json_data={"workflow_folder_paths": ["/library/workflows"]})
)
payload = json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert recorded == [["/library/workflows"]]
# The pseudo-key never lands in the generic set() path.
assert handler._settings.set_calls == []
assert "workflow_folder_paths" not in handler._settings._values
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,394 @@
"""DownloadManager support for the "workflow" model type.
Covers type routing (Workflows/ComfyWorkflows), the opt-in gate, existence
checks against the workflow scanner, the default save root, file selection
for weight-less workflow versions, and the rule that workflow archives are
kept byte-identical (never extracted).
"""
import json
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from py.services import aria2_transfer_state
from py.services import download_manager
from py.services.download_manager import DownloadManager
from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import SettingsManager, get_settings_manager
from py.utils.models import WorkflowMetadata
@pytest.fixture(autouse=True)
def reset_download_manager():
"""Ensure each test operates on a fresh singleton."""
DownloadManager._instance = None
yield
DownloadManager._instance = None
@pytest.fixture(autouse=True)
def isolate_settings(monkeypatch, tmp_path):
"""Point settings writes at a temporary directory to avoid touching real files."""
manager = get_settings_manager()
default_settings = manager._get_default_settings()
default_settings.update(
{
"enable_workflows": True,
"skip_previously_downloaded_model_versions": False,
"download_skip_base_models": [],
"download_path_templates": {},
"download_filename_templates": {},
}
)
monkeypatch.setattr(manager, "settings", default_settings)
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
@pytest.fixture(autouse=True)
def isolate_aria2_state(monkeypatch, tmp_path):
state_path = tmp_path / "cache" / "aria2" / "downloads.json"
monkeypatch.setattr(
aria2_transfer_state,
"get_aria2_state_path",
lambda: str(state_path),
)
class DummyScanner:
def __init__(self, exists: bool = False, roots=None):
self.exists = exists
self.calls = []
self._roots = list(roots or [])
self._cache = SimpleNamespace(raw_data=[])
async def check_model_version_exists(self, version_id):
self.calls.append(version_id)
return self.exists
async def get_cached_data(self):
return self._cache
def get_model_roots(self):
return list(self._roots)
@pytest.fixture
def scanners(monkeypatch):
lora_scanner = DummyScanner()
checkpoint_scanner = DummyScanner()
embedding_scanner = DummyScanner()
other_scanner = DummyScanner()
workflow_scanner = DummyScanner()
monkeypatch.setattr(
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner)
)
monkeypatch.setattr(
ServiceRegistry,
"get_checkpoint_scanner",
AsyncMock(return_value=checkpoint_scanner),
)
monkeypatch.setattr(
ServiceRegistry,
"get_embedding_scanner",
AsyncMock(return_value=embedding_scanner),
)
monkeypatch.setattr(
ServiceRegistry,
"get_other_scanner",
AsyncMock(return_value=other_scanner),
)
monkeypatch.setattr(
ServiceRegistry,
"get_workflow_scanner",
AsyncMock(return_value=workflow_scanner),
)
return SimpleNamespace(
lora=lora_scanner,
checkpoint=checkpoint_scanner,
embedding=embedding_scanner,
other=other_scanner,
workflow=workflow_scanner,
)
def _workflow_payload(civitai_type: str, *, files=None) -> dict:
return {
"id": 42,
"model": {"type": civitai_type, "tags": ["utility"], "name": "Flow"},
"baseModel": "SDXL 1.0",
"creator": {"username": "Author"},
"files": files
or [
{
"id": 7,
"type": "Config",
"primary": True,
"downloadUrl": "https://example.invalid/flow.json",
"name": "flow.json",
"hashes": {"SHA256": "ab" * 32},
}
],
}
@pytest.fixture
def metadata_provider(monkeypatch):
class DummyProvider:
def __init__(self):
self.calls = []
self.payload = _workflow_payload("Workflows")
async def get_model_version(self, model_id, model_version_id):
self.calls.append((model_id, model_version_id))
return self.payload
provider = DummyProvider()
monkeypatch.setattr(
download_manager,
"get_default_metadata_provider",
AsyncMock(return_value=provider),
)
return provider
def _capture_execute(monkeypatch, captured):
async def fake_execute_download(self, **kwargs):
captured.update(kwargs)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
@pytest.mark.asyncio
@pytest.mark.parametrize("civitai_type", ["Workflows", "ComfyWorkflows"])
async def test_download_accepts_workflow_model_types(
monkeypatch, scanners, metadata_provider, tmp_path, civitai_type
):
metadata_provider.payload = _workflow_payload(civitai_type)
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is True
assert captured["model_type"] == "workflow"
assert isinstance(captured["metadata"], WorkflowMetadata)
assert captured["metadata"].file_name == "flow"
@pytest.mark.asyncio
async def test_download_rejects_workflows_when_feature_disabled(
monkeypatch, scanners, metadata_provider, tmp_path
):
manager_settings = get_settings_manager()
manager_settings.settings["enable_workflows"] = False
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is False
assert result["reason"] == "workflows_disabled"
@pytest.mark.asyncio
async def test_early_gate_checks_workflow_scanner(
monkeypatch, scanners, metadata_provider, tmp_path
):
scanners.workflow.exists = True
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is False
assert "workflow library" in result["error"]
assert 99 in scanners.workflow.calls
@pytest.mark.asyncio
async def test_early_gate_skips_workflow_scanner_when_disabled(
monkeypatch, scanners, metadata_provider, tmp_path
):
get_settings_manager().settings["enable_workflows"] = False
# Route the download to a non-workflow type so the run proceeds.
metadata_provider.payload = {
"id": 42,
"model": {"type": "LORA", "tags": []},
"baseModel": "SDXL 1.0",
"files": [
{
"type": "Model",
"primary": True,
"downloadUrl": "https://example.invalid/file.safetensors",
"name": "file.safetensors",
}
],
}
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is True
assert scanners.workflow.calls == []
@pytest.mark.asyncio
async def test_default_paths_use_first_workflow_root(
monkeypatch, scanners, metadata_provider, tmp_path
):
workflow_root = tmp_path / "workflows"
workflow_root.mkdir()
scanners.workflow._roots = [str(workflow_root)]
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
assert captured["save_dir"] == str(workflow_root)
@pytest.mark.asyncio
async def test_default_paths_error_without_workflow_root(
monkeypatch, scanners, metadata_provider
):
scanners.workflow._roots = []
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is False
assert "workflow folder" in result["error"]
@pytest.mark.asyncio
async def test_scanner_dispatch_resolves_workflow(monkeypatch, scanners):
manager = DownloadManager()
scanner = await manager._get_scanner_for_model_type("workflow")
assert scanner is scanners.workflow
def test_select_workflow_download_file_ranking():
files = [
{"id": 1, "type": "Other", "name": "notes.txt"},
{"id": 2, "type": "Archive", "name": "pack.zip"},
{"id": 3, "type": "Config", "name": "flow.json"},
]
selected = DownloadManager._select_workflow_download_file(files)
assert selected["name"] == "flow.json"
# Without a JSON the archive wins; a primary flag breaks ties.
files = files[:2]
assert DownloadManager._select_workflow_download_file(files)["name"] == "pack.zip"
files[0]["primary"] = True
assert DownloadManager._select_workflow_download_file(files)["name"] == "notes.txt"
assert DownloadManager._select_workflow_download_file([]) is None
def test_build_metadata_for_resume_uses_workflow_metadata():
manager = DownloadManager()
metadata = manager._build_metadata_for_resume(
model_type="workflow",
version_info=_workflow_payload("Workflows"),
file_info={"name": "flow.zip", "hashes": {}},
save_path="/tmp/flow.zip",
)
assert isinstance(metadata, WorkflowMetadata)
assert metadata.file_name == "flow"
@pytest.mark.asyncio
async def test_workflow_download_keeps_zip_archive_intact(
monkeypatch, scanners, tmp_path
):
"""A downloaded workflow zip is the library artifact: never extracted."""
workflow = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
}
zip_path = tmp_path / "pack.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("workflow.json", json.dumps(workflow))
zip_bytes = zip_path.read_bytes()
async def fake_download_file(self, url, save_path, **kwargs):
with open(save_path, "wb") as handle:
handle.write(zip_bytes)
return True, ""
monkeypatch.setattr(
DownloadManager, "_download_model_file", fake_download_file, raising=False
)
added = []
class PipelineScanner:
def _find_root_for_file(self, file_path):
return None
def adjust_metadata(self, metadata, file_path, root_path):
return metadata
async def add_model_to_cache(self, metadata_dict, relative_path):
added.append(metadata_dict)
monkeypatch.setattr(
ServiceRegistry,
"get_workflow_scanner",
AsyncMock(return_value=PipelineScanner()),
)
save_path = tmp_path / "pack_downloaded.zip"
metadata = WorkflowMetadata(
file_name="pack_downloaded",
model_name="pack_downloaded",
file_path=str(save_path),
size=0,
modified=0,
sha256="",
base_model="Unknown",
preview_url="",
)
manager = DownloadManager()
result = await manager._execute_download(
download_urls=["https://example.invalid/pack.zip"],
save_dir=str(tmp_path),
metadata=metadata,
version_info={"id": 42, "model": {"type": "Workflows"}, "images": []},
relative_path="",
model_type="workflow",
)
assert result["success"] is True
# The archive is still there, unextracted, and no member files appeared.
assert save_path.exists()
assert save_path.read_bytes() == zip_bytes
assert not (tmp_path / "workflow.json").exists()
assert added and added[0]["file_path"].endswith("pack_downloaded.zip")
@@ -1080,3 +1080,122 @@ async def test_delete_model_falls_back_when_staging_fails(
# staging parent, if left behind by the rollback, holds no data).
staging_parent = root / PENDING_DELETE_DIR_NAME
assert not staging_parent.exists() or not any(staging_parent.iterdir())
@pytest.mark.asyncio
async def test_delete_model_artifacts_removes_derived_workflow_document(tmp_path: Path):
from py.services.model_lifecycle_service import delete_model_artifacts
artifact = tmp_path / "pack.zip"
artifact.write_bytes(b"zip")
derived = tmp_path / "pack.workflow.json"
derived.write_text("{}", encoding="utf-8")
metadata = tmp_path / "pack.metadata.json"
metadata.write_text("{}", encoding="utf-8")
deleted = await delete_model_artifacts(
str(tmp_path), "pack", main_extension=".zip"
)
assert not artifact.exists()
assert not derived.exists()
assert not metadata.exists()
assert any(str(name).endswith("pack.workflow.json") for name in deleted)
@pytest.mark.asyncio
async def test_delete_model_artifacts_image_artifact_deleted_once(tmp_path: Path):
"""An image workflow artifact doubles as its own preview: same path."""
from py.services.model_lifecycle_service import delete_model_artifacts
artifact = tmp_path / "flow.png"
artifact.write_bytes(b"png")
deleted = await delete_model_artifacts(
str(tmp_path), "flow", main_extension=".png"
)
assert not artifact.exists()
assert len(deleted) == len(set(deleted))
@pytest.mark.asyncio
async def test_rename_model_carries_derived_workflow_document(tmp_path: Path):
old_name = "pack"
new_name = "pack-renamed"
model_path = tmp_path / f"{old_name}.zip"
model_path.write_bytes(b"zip")
derived = tmp_path / f"{old_name}.workflow.json"
derived.write_text("{}", encoding="utf-8")
metadata_path = tmp_path / f"{old_name}.metadata.json"
metadata_payload = {
"file_name": old_name,
"file_path": model_path.as_posix(),
}
metadata_path.write_text(json.dumps(metadata_payload))
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
scanner = DummyScanner()
metadata_manager = PassthroughMetadataManager()
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=metadata_manager,
metadata_loader=metadata_loader,
)
result = await service.rename_model(
file_path=model_path.as_posix(),
new_file_name=new_name,
)
assert (tmp_path / f"{new_name}.zip").exists()
assert (tmp_path / f"{new_name}.workflow.json").exists()
assert not derived.exists()
assert isinstance(result["new_file_path"], str)
assert result["new_file_path"].endswith(f"{new_name}.zip")
@pytest.mark.asyncio
async def test_rename_image_workflow_artifact_renames_main_file_once(tmp_path: Path):
"""The image artifact matches both the main-file and a preview pattern."""
old_name = "flow"
new_name = "flow-renamed"
model_path = tmp_path / f"{old_name}.png"
model_path.write_bytes(b"png")
metadata_path = tmp_path / f"{old_name}.metadata.json"
metadata_payload = {
"file_name": old_name,
"file_path": model_path.as_posix(),
"preview_url": model_path.as_posix(),
}
metadata_path.write_text(json.dumps(metadata_payload))
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
scanner = DummyScanner()
metadata_manager = PassthroughMetadataManager()
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=metadata_manager,
metadata_loader=metadata_loader,
)
result = await service.rename_model(
file_path=model_path.as_posix(),
new_file_name=new_name,
)
assert (tmp_path / f"{new_name}.png").exists()
assert not model_path.exists()
renamed = result["renamed_files"]
assert len(renamed) == len(set(renamed))
+17
View File
@@ -268,6 +268,23 @@ async def test_initialize_cache_populates_cache(tmp_path: Path):
assert sorted(cache.folders) == ["", "nested"]
@pytest.mark.asyncio
async def test_force_refresh_without_cache_falls_back_to_full_initialization(tmp_path: Path):
"""A scan fired before background init has run has no cache to reconcile
against; it must initialize from scratch instead of crashing."""
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
assert scanner._cache is None
cache = await scanner.get_cached_data(force_refresh=True)
assert scanner._cache is not None
assert {item["file_path"] for item in cache.raw_data} == {
_normalize_path(tmp_path / "one.txt"),
_normalize_path(tmp_path / "nested" / "two.txt"),
}
@pytest.mark.asyncio
async def test_initialize_cache_sync_returns_result_without_mutating_state(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
+41
View File
@@ -1888,3 +1888,44 @@ def test_download_root_rules_accept_remote_only_base_models(manager, monkeypatch
"root": "/loras-archive",
}
]
def test_get_workflow_folder_paths_defaults_to_empty(manager):
assert manager.get_workflow_folder_paths() == []
def test_set_workflow_folder_paths_merges_into_active_library(manager):
_configure_lora_roots(manager, "/loras-main")
result = manager.set_workflow_folder_paths(["/workflows", " /workflows ", ""])
assert result == ["/workflows"]
# Core keys are preserved; workflows rides the same folder_paths map.
assert manager.settings["folder_paths"] == {
"loras": ["/loras-main"],
"workflows": ["/workflows"],
}
assert manager.get_workflow_folder_paths() == ["/workflows"]
active = manager.get_active_library()
assert active.get("folder_paths", {}).get("workflows") == ["/workflows"]
def test_set_workflow_folder_paths_empty_removes_key(manager):
manager.set_workflow_folder_paths(["/workflows"])
result = manager.set_workflow_folder_paths([])
assert result == []
assert "workflows" not in manager.settings["folder_paths"]
assert manager.get_workflow_folder_paths() == []
def test_set_workflow_folder_paths_rejects_overlap_with_other_library(manager):
manager.settings["libraries"] = {
"default": manager.get_active_library(),
"other": {"folder_paths": {"workflows": ["/shared-workflows"]}},
}
manager.settings["active_library"] = "default"
with pytest.raises(ValueError):
manager.set_workflow_folder_paths(["/shared-workflows"])
+238
View File
@@ -0,0 +1,238 @@
"""Tests for WorkflowScanner: opt-in roots, sidecar exclusion, metadata creation."""
import json
import os
import pytest
from py import config as config_module
from py.services.model_scanner import ModelScanner
from py.services.workflow_scanner import WorkflowScanner
from py.utils.models import WorkflowMetadata
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
@pytest.fixture(autouse=True)
def reset_model_scanner_singletons():
ModelScanner._instances.clear()
ModelScanner._locks.clear()
yield
ModelScanner._instances.clear()
ModelScanner._locks.clear()
@pytest.fixture
def workflow_root(tmp_path, monkeypatch):
root = tmp_path / "workflows"
root.mkdir()
monkeypatch.setattr(config_module.config, "workflows_roots", [_normalize(root)])
monkeypatch.setattr(config_module.config, "extra_workflows_roots", [])
return root
@pytest.fixture
def enable_workflows(monkeypatch):
"""Enable the feature without set() side effects (root refresh, rescan)."""
from py.services.settings_manager import get_settings_manager
monkeypatch.setattr(
get_settings_manager(), "is_workflows_enabled", lambda: True
)
def _make_scanner() -> WorkflowScanner:
"""Create a scanner without __init__ to avoid async initialization."""
scanner = object.__new__(WorkflowScanner)
scanner.model_type = "workflow"
scanner.model_class = WorkflowMetadata
scanner.file_extensions = {".json", ".zip", ".png", ".webp"}
return scanner
def test_roots_empty_while_feature_disabled(workflow_root, monkeypatch):
from py.services.settings_manager import get_settings_manager
monkeypatch.setattr(
get_settings_manager(), "is_workflows_enabled", lambda: False
)
scanner = _make_scanner()
assert scanner.get_model_roots() == []
def test_roots_listed_while_feature_enabled(workflow_root, enable_workflows):
scanner = _make_scanner()
assert scanner.get_model_roots() == [_normalize(workflow_root)]
def test_filename_filter_rejects_sidecars(enable_workflows):
scanner = _make_scanner()
assert scanner._is_indexable_filename("flow.json") is True
assert scanner._is_indexable_filename("flow.metadata.json") is False
assert scanner._is_indexable_filename("FLOW.METADATA.JSON") is False
# The derived workflow document of a container artifact is also a sidecar.
assert scanner._is_indexable_filename("flow.workflow.json") is False
assert scanner._is_indexable_filename("FLOW.WORKFLOW.JSON") is False
async def test_create_default_metadata_parses_workflow(workflow_root, enable_workflows, tmp_path):
workflow = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
"2": {"class_type": "LoraLoader", "inputs": {"lora_name": "civitai:1@2"}},
}
file_path = workflow_root / "my_flow.json"
file_path.write_text(json.dumps(workflow), encoding="utf-8")
scanner = _make_scanner()
metadata = await scanner._create_default_metadata(str(file_path))
assert metadata is not None
assert metadata.file_name == "my_flow"
assert metadata.sub_type == "workflow"
assert metadata.workflow_format == "api"
assert metadata.node_count == 2
assert metadata.sha256
assert metadata.model_refs == ["base.safetensors"]
assert metadata.civitai_refs == ["civitai:1@2"]
assert metadata.from_civitai is False
async def test_create_default_metadata_skips_non_workflow_json(workflow_root, enable_workflows):
file_path = workflow_root / "settings.json"
file_path.write_text(json.dumps({"some": "config"}), encoding="utf-8")
scanner = _make_scanner()
assert await scanner._create_default_metadata(str(file_path)) is None
async def test_create_default_metadata_skips_sidecar(workflow_root, enable_workflows):
file_path = workflow_root / "my_flow.metadata.json"
file_path.write_text(json.dumps({"file_name": "my_flow"}), encoding="utf-8")
scanner = _make_scanner()
assert await scanner._create_default_metadata(str(file_path)) is None
async def test_create_default_metadata_from_zip_artifact(workflow_root, enable_workflows):
import zipfile
workflow = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
}
file_path = workflow_root / "pack.zip"
with zipfile.ZipFile(file_path, "w") as archive:
archive.writestr("workflow.json", json.dumps(workflow))
archive.writestr("readme.txt", "hello")
scanner = _make_scanner()
metadata = await scanner._create_default_metadata(str(file_path))
assert metadata is not None
assert metadata.file_name == "pack"
assert metadata.workflow_format == "api"
assert metadata.node_count == 1
assert metadata.model_refs == ["base.safetensors"]
# The artifact is hashed, not the extracted document.
assert metadata.sha256
# The derived workflow document is materialized as a sidecar.
derived = workflow_root / "pack.workflow.json"
assert derived.exists()
assert json.loads(derived.read_text(encoding="utf-8")) == workflow
async def test_create_default_metadata_skips_zip_without_workflow(workflow_root, enable_workflows):
import zipfile
file_path = workflow_root / "images.zip"
with zipfile.ZipFile(file_path, "w") as archive:
archive.writestr("note.txt", "no graphs here")
scanner = _make_scanner()
assert await scanner._create_default_metadata(str(file_path)) is None
assert not (workflow_root / "images.workflow.json").exists()
async def test_create_default_metadata_image_artifact_self_preview(workflow_root, enable_workflows):
PIL = pytest.importorskip("PIL.Image")
from py.utils.exif_utils import ExifUtils
workflow = {
"nodes": [{"id": 1, "type": "CheckpointLoaderSimple"}],
"links": [],
}
file_path = workflow_root / "flow.png"
PIL.new("RGB", (8, 8)).save(file_path, format="PNG")
ExifUtils.embed_workflow(str(file_path), json.dumps(workflow))
scanner = _make_scanner()
metadata = await scanner._create_default_metadata(str(file_path))
assert metadata is not None
assert metadata.workflow_format == "ui"
# The image artifact doubles as its own preview.
assert metadata.preview_url.replace(os.sep, "/").endswith("flow.png")
async def test_create_default_metadata_plain_image_skipped(workflow_root, enable_workflows):
PIL = pytest.importorskip("PIL.Image")
file_path = workflow_root / "preview.png"
PIL.new("RGB", (8, 8)).save(file_path, format="PNG")
scanner = _make_scanner()
assert await scanner._create_default_metadata(str(file_path)) is None
def test_adjust_metadata_fills_missing_analysis(workflow_root, enable_workflows):
"""Downloaded entries carry CivitAI metadata but no analysis fields yet."""
import zipfile
workflow = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
}
file_path = workflow_root / "downloaded.zip"
with zipfile.ZipFile(file_path, "w") as archive:
archive.writestr("workflow.json", json.dumps(workflow))
metadata = WorkflowMetadata(
file_name="downloaded",
model_name="downloaded",
file_path=_normalize(file_path),
size=file_path.stat().st_size,
modified=0,
sha256="abc",
base_model="Unknown",
preview_url="",
from_civitai=True,
)
scanner = _make_scanner()
adjusted = scanner.adjust_metadata(metadata, str(file_path), None)
assert adjusted.workflow_format == "api"
assert adjusted.node_count == 1
assert adjusted.model_refs == ["base.safetensors"]
assert (workflow_root / "downloaded.workflow.json").exists()
def test_adjust_metadata_keeps_populated_analysis(workflow_root, enable_workflows):
metadata = WorkflowMetadata(
file_name="flow",
model_name="flow",
file_path=_normalize(workflow_root / "flow.json"),
size=1,
modified=0,
sha256="abc",
base_model="Unknown",
preview_url="",
workflow_format="ui",
node_count=7,
)
scanner = _make_scanner()
adjusted = scanner.adjust_metadata(metadata, str(workflow_root / "flow.json"), None)
assert adjusted.node_count == 7 # untouched: no re-extraction
+101
View File
@@ -0,0 +1,101 @@
"""Tests for py.utils.workflow_analysis: sniffing and graph extraction."""
import json
from py.utils.workflow_analysis import (
WORKFLOW_FORMAT_API,
WORKFLOW_FORMAT_UI,
analyze_workflow,
analyze_workflow_file,
load_workflow_file,
sniff_workflow_text,
)
UI_WORKFLOW = {
"nodes": [
{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": ["sd_xl_base_1.0.safetensors"]},
{"id": 2, "type": "LoraLoader", "widgets_values": ["civitai:12345@67890", 1.0, 1.0]},
{"id": 3, "type": "KSampler", "widgets_values": [42]},
],
"links": [],
"groups": [],
"version": 0.4,
}
API_WORKFLOW = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "v1-5-pruned.safetensors"}},
"2": {"class_type": "LoraLoader", "inputs": {"lora_name": "civitai:111@222", "strength_model": 1.0}},
"3": {"class_type": "SaveImage", "inputs": {"filename_prefix": "ComfyUI"}},
}
def test_sniff_ui_format():
assert sniff_workflow_text(json.dumps(UI_WORKFLOW)) == WORKFLOW_FORMAT_UI
def test_sniff_api_format():
assert sniff_workflow_text(json.dumps(API_WORKFLOW)) == WORKFLOW_FORMAT_API
def test_sniff_rejects_non_workflow_json():
assert sniff_workflow_text(json.dumps({"setting": True, "other": 1})) is None
assert sniff_workflow_text(json.dumps(["a", "b"])) is None
assert sniff_workflow_text("not json at all") is None
# A sidecar-shaped document (flat metadata fields) is not a workflow.
assert sniff_workflow_text(json.dumps({"file_name": "x", "sha256": "abc"})) is None
def test_analyze_ui_format_extracts_classes_and_refs():
analysis = analyze_workflow(UI_WORKFLOW)
assert analysis is not None
assert analysis.workflow_format == WORKFLOW_FORMAT_UI
assert analysis.node_count == 3
assert analysis.node_classes == ["CheckpointLoaderSimple", "KSampler", "LoraLoader"]
assert analysis.model_refs == ["sd_xl_base_1.0.safetensors"]
assert analysis.civitai_refs == ["civitai:12345@67890"]
def test_analyze_api_format_extracts_classes_and_refs():
analysis = analyze_workflow(API_WORKFLOW)
assert analysis is not None
assert analysis.workflow_format == WORKFLOW_FORMAT_API
assert analysis.node_count == 3
assert "SaveImage" in analysis.node_classes
assert analysis.model_refs == ["v1-5-pruned.safetensors"]
assert analysis.civitai_refs == ["civitai:111@222"]
def test_analyze_rejects_non_workflow():
assert analyze_workflow({"foo": "bar"}) is None
def test_analyze_skips_prompt_text_with_extension_like_suffix(tmp_path):
# Prompt strings contain spaces and must not become model refs.
workflow = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "a beautiful landscape, best quality.safetensors style"},
}
}
analysis = analyze_workflow(workflow)
assert analysis is not None
assert analysis.model_refs == []
def test_analyze_workflow_file_roundtrip(tmp_path):
path = tmp_path / "flow.json"
path.write_text(json.dumps(API_WORKFLOW), encoding="utf-8")
analysis = analyze_workflow_file(str(path))
assert analysis is not None
assert analysis.workflow_format == WORKFLOW_FORMAT_API
def test_load_workflow_file_rejects_sidecar(tmp_path):
sidecar = tmp_path / "flow.metadata.json"
sidecar.write_text(json.dumps({"file_name": "flow", "sha256": "x"}), encoding="utf-8")
assert load_workflow_file(str(sidecar)) is None
def test_load_workflow_file_handles_missing(tmp_path):
assert load_workflow_file(str(tmp_path / "nope.json")) is None
+200
View File
@@ -0,0 +1,200 @@
"""Tests for workflow document extraction from library artifacts."""
import json
import os
import zipfile
import pytest
from py.utils.workflow_extraction import (
WORKFLOW_DOCUMENT_SUFFIX,
extract_workflow_document,
get_workflow_document_path,
load_artifact_workflow_document,
materialize_workflow_document,
)
API_WORKFLOW = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
"2": {"class_type": "LoraLoader", "inputs": {"lora_name": "lora.safetensors"}},
}
UI_WORKFLOW = {
"nodes": [{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": ["base.safetensors"]}],
"links": [],
}
def _write_json(path, payload):
path.write_text(json.dumps(payload), encoding="utf-8")
def _write_zip(path, members):
with zipfile.ZipFile(path, "w") as archive:
for name, payload in members.items():
archive.writestr(name, payload)
def _write_png_with_workflow(path, workflow):
PIL = pytest.importorskip("PIL.Image")
from py.utils.exif_utils import ExifUtils
img = PIL.new("RGB", (8, 8), color=(0, 0, 0))
img.save(path, format="PNG")
ExifUtils.embed_workflow(str(path), json.dumps(workflow))
def _write_webp_with_workflow(path, workflow):
PIL = pytest.importorskip("PIL.Image")
from py.utils.exif_utils import ExifUtils
img = PIL.new("RGB", (8, 8), color=(0, 0, 0))
img.save(path, format="WEBP")
ExifUtils.embed_workflow(str(path), json.dumps(workflow))
def test_document_path_identity_for_json(tmp_path):
artifact = tmp_path / "flow.json"
assert get_workflow_document_path(str(artifact)) == str(artifact)
def test_document_path_derived_for_containers(tmp_path):
artifact = tmp_path / "flow.zip"
derived = get_workflow_document_path(str(artifact))
assert derived == str(tmp_path / f"flow{WORKFLOW_DOCUMENT_SUFFIX}")
image = tmp_path / "flow.png"
assert get_workflow_document_path(str(image)) == str(
tmp_path / f"flow{WORKFLOW_DOCUMENT_SUFFIX}"
)
def test_extract_json_artifact(tmp_path):
artifact = tmp_path / "flow.json"
_write_json(artifact, API_WORKFLOW)
document = extract_workflow_document(str(artifact))
assert document == API_WORKFLOW
def test_extract_rejects_non_workflow_json(tmp_path):
artifact = tmp_path / "settings.json"
_write_json(artifact, {"some": "config"})
assert extract_workflow_document(str(artifact)) is None
assert materialize_workflow_document(str(artifact)) is None
def test_extract_from_zip_top_level(tmp_path):
artifact = tmp_path / "pack.zip"
_write_zip(
artifact,
{
"workflow.json": json.dumps(UI_WORKFLOW),
"readme.txt": "hello",
},
)
document = extract_workflow_document(str(artifact))
assert document == UI_WORKFLOW
def test_extract_from_zip_prefers_shallow_candidates(tmp_path):
artifact = tmp_path / "pack.zip"
_write_zip(
artifact,
{
"nested/deep/config.json": json.dumps(API_WORKFLOW),
"top.json": json.dumps(UI_WORKFLOW),
},
)
document = extract_workflow_document(str(artifact))
assert document == UI_WORKFLOW
def test_extract_from_zip_skips_macosx_and_hidden(tmp_path):
artifact = tmp_path / "pack.zip"
_write_zip(
artifact,
{
"__MACOSX/workflow.json": json.dumps(UI_WORKFLOW),
".hidden/workflow.json": json.dumps(UI_WORKFLOW),
"real.json": json.dumps(API_WORKFLOW),
},
)
document = extract_workflow_document(str(artifact))
assert document == API_WORKFLOW
def test_extract_from_zip_without_workflow_returns_none(tmp_path):
artifact = tmp_path / "images.zip"
_write_zip(artifact, {"note.txt": "no graphs here", "config.json": "{}"})
assert extract_workflow_document(str(artifact)) is None
def test_extract_from_corrupt_zip_returns_none(tmp_path):
artifact = tmp_path / "broken.zip"
artifact.write_bytes(b"not a zip at all")
assert extract_workflow_document(str(artifact)) is None
def test_extract_from_png_with_embedded_workflow(tmp_path):
artifact = tmp_path / "flow.png"
_write_png_with_workflow(artifact, UI_WORKFLOW)
document = extract_workflow_document(str(artifact))
assert document == UI_WORKFLOW
def test_extract_from_webp_with_embedded_workflow(tmp_path):
artifact = tmp_path / "flow.webp"
_write_webp_with_workflow(artifact, UI_WORKFLOW)
document = extract_workflow_document(str(artifact))
assert document == UI_WORKFLOW
def test_extract_from_plain_image_returns_none(tmp_path):
PIL = pytest.importorskip("PIL.Image")
artifact = tmp_path / "plain.png"
PIL.new("RGB", (8, 8)).save(artifact, format="PNG")
assert extract_workflow_document(str(artifact)) is None
assert materialize_workflow_document(str(artifact)) is None
def test_materialize_writes_derived_document_once(tmp_path):
artifact = tmp_path / "pack.zip"
_write_zip(artifact, {"workflow.json": json.dumps(API_WORKFLOW)})
derived = materialize_workflow_document(str(artifact))
assert derived == str(tmp_path / f"pack{WORKFLOW_DOCUMENT_SUFFIX}")
with open(derived, "r", encoding="utf-8") as handle:
assert json.load(handle) == API_WORKFLOW
# A fresh derived file is reused without re-extracting: corrupt the zip
# and confirm the cached document is still returned.
artifact_mtime = os.path.getmtime(artifact)
os.utime(derived, (artifact_mtime + 1, artifact_mtime + 1))
artifact.write_bytes(b"corrupted")
assert materialize_workflow_document(str(artifact)) == derived
def test_materialize_reextracts_when_artifact_is_newer(tmp_path):
artifact = tmp_path / "pack.zip"
_write_zip(artifact, {"workflow.json": json.dumps(API_WORKFLOW)})
derived = materialize_workflow_document(str(artifact))
# Replace the artifact with a newer mtime than the derived document.
_write_zip(artifact, {"workflow.json": json.dumps(UI_WORKFLOW)})
artifact_mtime = os.path.getmtime(artifact)
os.utime(artifact, (artifact_mtime + 10, artifact_mtime + 10))
assert materialize_workflow_document(str(artifact)) == derived
with open(derived, "r", encoding="utf-8") as handle:
assert json.load(handle) == UI_WORKFLOW
def test_load_artifact_workflow_document_round_trip(tmp_path):
artifact = tmp_path / "pack.zip"
_write_zip(artifact, {"workflow.json": json.dumps(API_WORKFLOW)})
assert load_artifact_workflow_document(str(artifact)) == API_WORKFLOW
bare = tmp_path / "bare.json"
_write_json(bare, UI_WORKFLOW)
assert load_artifact_workflow_document(str(bare)) == UI_WORKFLOW
@@ -15904,6 +15904,34 @@ function findModeAccessor(node) {
return null;
}
function interceptModeChange(node, onModeChange) {
if (!node || typeof onModeChange !== "function") {
return;
}
if (!Array.isArray(node._lmModeChangeListeners)) {
Object.defineProperty(node, "_lmModeChangeListeners", {
configurable: true,
writable: true,
value: []
});
}
node._lmModeChangeListeners.push(onModeChange);
if (node._lmModeChangeIntercepted) {
return;
}
Object.defineProperty(node, "_lmModeChangeIntercepted", {
configurable: true,
writable: true,
value: true
});
const notify = (newMode, oldMode) => {
for (const listener of node._lmModeChangeListeners ?? []) {
try {
listener(newMode, oldMode);
} catch (listenerError) {
console.error("LoRA Manager: mode change listener failed", listenerError);
}
}
};
const delegate = findModeAccessor(node);
if (delegate && typeof delegate.get === "function" && typeof delegate.set === "function") {
Object.defineProperty(node, "mode", {
@@ -15915,7 +15943,7 @@ function interceptModeChange(node, onModeChange) {
const oldValue = delegate.get.call(this);
delegate.set.call(this, value);
if (oldValue !== value) {
onModeChange(value, oldValue);
notify(value, oldValue);
}
}
});
@@ -15931,7 +15959,7 @@ function interceptModeChange(node, onModeChange) {
const oldValue = currentMode;
currentMode = value;
if (oldValue !== value) {
onModeChange(value, oldValue);
notify(value, oldValue);
}
}
});
File diff suppressed because one or more lines are too long
+28 -1
View File
@@ -444,12 +444,18 @@ app.registerExtension({
* Load a workflow received from the backend onto the ComfyUI canvas.
*
* The payload (`detail`) is expected to be:
* { workflow: <workflow JSON>, name: <string>, recipe_id: <string> }
* { workflow: <workflow JSON>, name: <string>, recipe_id: <string>,
* lm_source?: { file_path: <string>, sha256: <string|null> } }
*
* Two formats are supported, detected by the presence of `class_type`
* entries (API/prompt format: { node_id: { class_type, inputs } }) versus
* the UI format ({ nodes: [...], links: [...] }).
*
* When `lm_source` is present (workflow library "Open in ComfyUI"), the
* provenance marker is stamped onto the loaded graph's `extra` so the
* write-back entry point (LoraManager.WorkflowWriteback) can match the
* open graph against the managed document.
*
* @param {Object} detail - message payload from the backend
*/
async loadWorkflowFromMessage(detail) {
@@ -476,12 +482,33 @@ app.registerExtension({
const workflowName = detail.name || "Recipe Workflow";
const lmSource = detail?.lm_source?.file_path
? {
file_path: detail.lm_source.file_path,
sha256: detail.lm_source.sha256 || null,
}
: null;
try {
if (isApiFormat) {
await app.loadApiJson(workflow, workflowName);
} else {
// UI format: the marker rides the document's `extra`, which
// loadGraphData restores onto the graph and serializes back —
// it survives tab switches and browser reloads with the draft.
if (lmSource) {
workflow.extra = { ...(workflow.extra || {}), lm_source: lmSource };
}
await app.loadGraphData(workflow, true, true, workflowName, { openSource: "file_button" });
}
// API format has no `extra` carrier, so stamp the live graph
// directly after the load.
if (lmSource && isApiFormat) {
const rootGraph = app.rootGraph ?? app.graph;
if (rootGraph) {
rootGraph.extra = { ...(rootGraph.extra || {}), lm_source: lmSource };
}
}
this._log("workflow loaded from backend message (%s): %s", isApiFormat ? "api" : "graph", workflowName);
} catch (error) {
console.error("LoRA Manager: failed to load workflow from backend message", error);
+394
View File
@@ -0,0 +1,394 @@
/**
* Write-back of the current ComfyUI graph into a LoRA Manager managed
* workflow ("Update LM-managed workflow").
*
* Correspondence: when LoRA Manager pushes a library workflow into ComfyUI
* ("Open in ComfyUI"), workflow_registry.js stamps the provenance marker
* `extra.lm_source = { file_path, sha256 }` onto the loaded graph. The marker
* rides the graph serialization, so it survives tab switches and browser
* reloads together with the workflow draft. Entry points below are only
* usable while the active graph carries that marker.
*
* Dirty tracking: the first `graphChanged` event after a load/write-back
* flips `marker.dirty` (persisted inside the marker, so reloads keep it).
* `graphChanged` is dispatched by ComfyUI's ChangeTracker only when the
* serialized state actually changed, so selection/panning never dirties.
*
* Entry points:
* - A command (`LoraManager.UpdateManagedWorkflow`), reachable from the
* command palette and the top menu bar (File group).
* - An item injected into the workflow actions dropdown (the "Graph ▾"
* toggle menu). The frontend offers no extension API for that menu, so
* the item is appended via DOM observation of the menu's trigger; if the
* markup changes upstream the injection silently stops and the command
* remains as the fallback.
* - The LM web UI card context menu ("Update from ComfyUI"): the backend
* routes its request here over the `lm_collect_workflow` WS channel and
* awaits the outcome via `/api/lm/workflows/collect-result`.
*
* The active graph's link state is reported to the backend
* (`/api/lm/workflows/comfyui-state`) on load/tab-switch/dirty-flip/
* write-back so the LM web UI can enable the menu entry only when the
* workflow is open in ComfyUI and has edits.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { lmUrl } from "./base_path.js";
import { showToast } from "./utils.js";
const MARKER_KEY = "lm_source";
const COMMAND_ID = "LoraManager.UpdateManagedWorkflow";
const MENU_LABEL = "Update LM-managed workflow";
const MENU_ICON = "pi pi-upload";
// data-testid of the workflow actions dropdown trigger in ComfyUI_frontend
// (WorkflowActionsDropdown.vue).
const DROPDOWN_TRIGGER_TESTID = "view-mode-toggle";
function getRootGraph() {
return app.rootGraph ?? app.graph ?? null;
}
/**
* The provenance marker of the active graph, or null when the open workflow
* was not loaded from the LoRA Manager library.
*/
export function getActiveLmSource() {
const marker = getRootGraph()?.extra?.[MARKER_KEY];
if (!marker || typeof marker.file_path !== "string" || !marker.file_path) {
return null;
}
return marker;
}
/**
* Serialize the current graph without the LM provenance marker: the marker
* is client-side bookkeeping and must not be persisted into the library
* document (the backend strips it too, defense in depth).
*/
function serializeWorkflowForWriteback() {
const graph = getRootGraph();
const data = JSON.parse(JSON.stringify(graph.serialize()));
if (data.extra && typeof data.extra === "object") {
delete data.extra[MARKER_KEY];
}
return data;
}
async function requestWriteback(marker, { force = false } = {}) {
const response = await fetch(lmUrl("/api/lm/workflows/content"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_path: marker.file_path,
workflow: serializeWorkflowForWriteback(),
base_sha256: marker.sha256 || "",
force,
}),
});
let payload = null;
try {
payload = await response.json();
} catch (_error) {
payload = null;
}
return { status: response.status, payload };
}
/**
* Report the active graph's link state to the LM backend (null state when
* the active graph is not LM-linked). Best-effort: failures are silent so a
* missing/older backend never spams the console.
*/
export async function reportLinkState() {
const marker = getActiveLmSource();
try {
await fetch(lmUrl("/api/lm/workflows/comfyui-state"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: api.clientId ?? api.initialClientId ?? "",
state: marker
? {
file_path: marker.file_path,
sha256: marker.sha256 || null,
dirty: marker.dirty === true,
}
: null,
}),
});
} catch (_error) {
// Backend unreachable or older version without the endpoint.
}
}
/**
* Core write-back: PUT the current graph into the managed document.
* Returns { success, sha256? , error? }. On success the marker is refreshed
* (new hash, dirty cleared) and the new state is reported. On 409 the user
* is asked to confirm the overwrite when `askOnConflict` is set.
*/
async function performWriteback(marker, { askOnConflict = true } = {}) {
let result;
try {
result = await requestWriteback(marker);
} catch (error) {
return { success: false, error: error.message };
}
if (result.status === 409) {
if (!askOnConflict) {
return { success: false, error: "conflict" };
}
const overwrite = window.confirm(
"The library copy of this workflow has changed since it was opened.\n" +
"Overwrite the LoRA Manager copy with the current graph?"
);
if (!overwrite) {
return { success: false, error: "cancelled" };
}
try {
result = await requestWriteback(marker, { force: true });
} catch (error) {
return { success: false, error: error.message };
}
}
if (result.payload?.success) {
if (result.payload.sha256) {
marker.sha256 = result.payload.sha256;
}
marker.dirty = false;
reportLinkState();
return { success: true, sha256: result.payload.sha256 ?? null };
}
return {
success: false,
error: result.payload?.error || `HTTP ${result.status}`,
};
}
/**
* Write the active graph back into its LoRA Manager managed workflow
* document. No-op (with a warning toast) when the graph carries no marker.
*/
export async function updateManagedWorkflow() {
const marker = getActiveLmSource();
if (!marker) {
showToast({
severity: "warn",
summary: "LoRA Manager",
detail: "This workflow is not linked to the LoRA Manager library. Open it via LoRA Manager first.",
life: 5000,
});
return;
}
const result = await performWriteback(marker);
if (result.success) {
showToast({
severity: "success",
summary: "LoRA Manager",
detail: "Workflow updated in the LoRA Manager library.",
life: 3000,
});
} else if (result.error !== "cancelled") {
showToast({
severity: "error",
summary: "LoRA Manager",
detail: `Failed to update the managed workflow: ${result.error}`,
life: 5000,
});
}
}
/**
* Answer an LM-web-UI "Update from ComfyUI" request. Only the tab whose
* active graph marker matches the requested file_path responds; the outcome
* is posted back to the backend, which resolves the pending LM-page request.
*/
async function handleCollectRequest(detail) {
const requestId = detail?.request_id;
const filePath = detail?.file_path;
if (!requestId || !filePath) {
return;
}
const marker = getActiveLmSource();
if (!marker || marker.file_path !== filePath) {
return;
}
const result = marker.dirty
? await performWriteback(marker)
: { success: false, error: "no_changes" };
try {
await fetch(lmUrl("/api/lm/workflows/collect-result"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId, ...result }),
});
} catch (error) {
console.error("LoRA Manager: failed to post collect result", error);
}
}
// ---------------------------------------------------------------------------
// Workflow actions dropdown injection
// ---------------------------------------------------------------------------
const ITEM_CLASSES =
"flex min-h-6 items-center gap-2 self-stretch rounded-sm p-2 outline-none";
const ITEM_ACTIVE_CLASSES = "cursor-pointer hover:bg-secondary-background-hover";
const ITEM_DISABLED_CLASSES = "cursor-default opacity-50";
const SEPARATOR_CLASSES = "my-1 w-full border-b border-border-subtle";
function buildMenuItem() {
const linked = getActiveLmSource() !== null;
const separator = document.createElement("div");
separator.className = SEPARATOR_CLASSES;
separator.setAttribute("role", "separator");
const item = document.createElement("div");
item.setAttribute("role", "menuitem");
item.className = `${ITEM_CLASSES} ${linked ? ITEM_ACTIVE_CLASSES : ITEM_DISABLED_CLASSES}`;
if (!linked) {
item.setAttribute("aria-disabled", "true");
item.title = "Only available for workflows opened from LoRA Manager";
}
const icon = document.createElement("i");
icon.className = MENU_ICON;
const label = document.createElement("span");
label.className = "flex-1";
label.textContent = MENU_LABEL;
item.append(icon, label);
if (linked) {
item.addEventListener("click", () => {
// Our node is not a reka-ui DropdownMenuItem, so the menu does not
// close on its own — close it the way Escape would.
const menu = item.closest('[role="menu"]');
(menu ?? document).dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
);
updateManagedWorkflow();
});
}
return [separator, item];
}
/**
* Append the write-back item to an open workflow actions menu, once per
* menu instance (the portal remounts on every open).
*/
function injectIntoMenu(menuElement) {
if (menuElement.dataset.lmWritebackInjected) {
return;
}
menuElement.dataset.lmWritebackInjected = "1";
for (const node of buildMenuItem()) {
menuElement.appendChild(node);
}
}
function findOpenWorkflowMenu() {
// The workflow actions menu is the reka-ui dropdown containing the
// core items (pencil = Rename, save = Save, download = Export); icon
// classes are locale-independent.
for (const menu of document.querySelectorAll('[role="menu"]')) {
if (
menu.querySelector(".pi-pencil") &&
menu.querySelector(".pi-save") &&
menu.querySelector(".pi-download")
) {
return menu;
}
}
return null;
}
function installDropdownInjection() {
let pollTimer = null;
const scheduleInjection = () => {
if (pollTimer != null) {
clearTimeout(pollTimer);
}
// The portal mounts asynchronously after the trigger click; poll
// briefly instead of assuming a fixed delay.
let attempts = 0;
const poll = () => {
pollTimer = null;
const menu = findOpenWorkflowMenu();
if (menu) {
injectIntoMenu(menu);
return;
}
attempts += 1;
if (attempts < 10) {
pollTimer = setTimeout(poll, 50);
}
};
pollTimer = setTimeout(poll, 0);
};
// The trigger itself mounts late (frontend boot) and can remount on mode
// switches, so observe its appearance and hook clicks on it.
const hookTrigger = () => {
const trigger = document.querySelector(`[data-testid="${DROPDOWN_TRIGGER_TESTID}"]`);
if (trigger && !trigger.dataset.lmWritebackHooked) {
trigger.dataset.lmWritebackHooked = "1";
trigger.addEventListener("click", scheduleInjection);
}
};
const observer = new MutationObserver(() => hookTrigger());
observer.observe(document.body, { childList: true, subtree: true });
hookTrigger();
}
app.registerExtension({
name: "LoraManager.WorkflowWriteback",
commands: [
{
id: COMMAND_ID,
label: MENU_LABEL,
menubarLabel: MENU_LABEL,
icon: MENU_ICON,
function: () => updateManagedWorkflow(),
},
],
menuCommands: [{ path: ["File"], commands: [COMMAND_ID] }],
setup() {
try {
installDropdownInjection();
} catch (error) {
console.warn("LoRA Manager: workflow dropdown injection unavailable", error);
}
// graphChanged is dispatched by ComfyUI's ChangeTracker only when the
// serialized state actually changed: the first one after a load or
// write-back is a genuine user edit.
api.addEventListener("graphChanged", () => {
const marker = getActiveLmSource();
if (marker && marker.dirty !== true) {
marker.dirty = true;
reportLinkState();
}
});
api.addEventListener("lm_collect_workflow", (event) =>
handleCollectRequest(event?.detail ?? {})
);
},
// Fires on initial load, workflow open, and tab switch: report which
// LM-managed workflow (if any) is active now.
afterConfigureGraph() {
reportLinkState();
},
});