Merge branch 'feature/workflow-management'
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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`,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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 +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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user